Sample code download

background

The Tab Bar framework is already familiar with the Tab Bar. Generally, it is only used to set two ICONS to represent the selected and unselected states, which is inevitably a little dull. Later on, many controls did some eye-catching animation when the tag was selected, but I think most of it was animation for animation’s sake. It wasn’t until LATER when I saw the animations on the Outlook client that I realized it could be combined with the user’s interaction.

Figure 1 Label ICONS follow gestures for different animations

Interesting, but this article is not intended to be an exact copy, there will be slight variations:


Figure 2. The final result of this article

Implementation analysis

Before we write the code, let’s discuss how to implement it. As you can probably guess, the TAB icon is obviously not an image anymore, but a custom UIView. It is not difficult to mount a view to the position of the original icon. It is slightly more complicated to implement the digital scroll wheel effect. Although the number is constantly scrolling, it actually displays a maximum of two numbers, i.e. only two labels are enough.

Based on the length, the article will not involve the right side of the clock effect, interested in direct reference to the source code.

Digital scroll wheel

Open the project TabBarInteraction and create a new file wheelView.swift, which is a subclass of UIView. First set up the initialization function:

class WheelView: UIView {
    required init? (coder aDecoder:NSCoder) {
        super.init(coder: aDecoder)
        setupView()
    }
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
    }
}
Copy the code

Then create two Label instances representing the next two labels in the scroll wheel:

private lazy var toplabel: UILabel = {
    return createDefaultLabel()
}()

private lazy var bottomLabel: UILabel = {
    return createDefaultLabel()
}()

private func createDefaultLabel() -> UILabel {
    let label = UILabel() 
    label.textAlignment = NSTextAlignment.center
    label.adjustsFontSizeToFitWidth = true
    label.translatesAutoresizingMaskIntoConstraints = false
    return label
}
Copy the code

Now complete the setupView() method, which adds the two labels to the view and then sets the constraint to align their edges with the layoutMarginsGuide.

private func setupView(a) {
    translatesAutoresizingMaskIntoConstraints = false
    for label in [toplabel, bottomLabel] {
        addSubview(label)
        NSLayoutConstraint.activate([
            label.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
            label.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor),
            label.leftAnchor.constraint(equalTo: layoutMarginsGuide.leftAnchor),
            label.rightAnchor.constraint(equalTo: layoutMarginsGuide.rightAnchor)
        ])
    }
}
Copy the code

Some people may ask that the current two labels are not overlapping state? Take your time, we’ll dynamically adjust their size and position based on the parameters. Add two instance variables progress and contents, representing the overall progress of the scroll and the entire content displayed, respectively.

var progress: Float = 0.0
var contents = [String] ()Copy the code

Based on these two variables, we will then calculate what the current two labels display and where they are scaled. All of this is done in progress’s didSet:

var progress: Float = 0.0 {
    didSet {
        progress = min(max(progress, 0.0), 1.0) 
        guard contents.count > 0 else { return }
        
        /** The contents of the next and next two labels are displayed according to progress and contents, and the compression degree and position of the label. * progress = 0.4, contents = ["A","B","C","D"] * * 1) topIndex = 4 * 0.4 = 1.6 Toplabel. text = contents[1] = "B" * bottomIndex = 1.6 + 1 = 2.6, Bottomlabel. text = contents[2] = "C" * * 2) This is the principle of implementation effect of roller * indexOffset = 1 = 0.6 * halfHeight = 1.6% bounds. The height / 2 * ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ * | ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ | scaleY | | * | | | | 1-0.6 = 0.4 | | translationY * | | topLabel | | -- -- -- -- -- -- -- -- -- - > | ┌ ─ topLabel ─ ┐ | -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- ┐ * | | | | | └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ * 0.6 | | - halfHeight ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ * | └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ | | | | | ┌ ─ toplabel ─ ┐ | * └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ | | └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ * | | - > | ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ | * ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ | | | bottomLabel | | | * ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ | scaleY | | | | └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ * | | | | | | 0.6 ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ | translationY | └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ * | | bottomLabel | | -- -- -- -- -- -- -- -- -- - > | | bottomLabel | | -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- ┘ * | | | | | └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ | halfHeight * 0.4 * | └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ | | | * └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ * * you can imagine, when indexOffset from 0.. In the process of <1, * topLabel shrinks from full view to 0, while bottomLabel does the opposite and grows to full view, forming a complete scroll */
        let topIndex = min(max(0.0.Float(contents.count) * progress), Float(contents.count - 1))
        let bottomIndex = min(topIndex + 1.Float(contents.count - 1))
        let indexOffset =  topIndex.truncatingRemainder(dividingBy: 1)
        
        toplabel.text = contents[Int(topIndex)]
        toplabel.transform = CGAffineTransform(scaleX: 1.0, y: CGFloat(1 - indexOffset))
            .concatenating(CGAffineTransform(translationX: 0, y: -(toplabel.bounds.height / 2) * CGFloat(indexOffset)))
            
        bottomLabel.text = contents[Int(bottomIndex)]
        bottomLabel.transform = CGAffineTransform(scaleX: 1.0, y: CGFloat(indexOffset))
            .concatenating(CGAffineTransform(translationX: 0, y: (bottomLabel.bounds.height / 2) * (1 - CGFloat(indexOffset))))
    }
}
Copy the code

Finally, we need to expose some styles for customization:

extension WheelView {
    /// the foreground scenery changes the event
    override func tintColorDidChange(a) {
        [toplabel, bottomLabel].forEach { $0.textColor = tintColor }
        layer.borderColor = tintColor.cgColor
    }
    / / / the background color
    override var backgroundColor: UIColor? {
        get { return toplabel.backgroundColor }
        set { [toplabel, bottomLabel].forEach { $0.backgroundColor = newValue } }
    }
    /// frame width
    var borderWidth: CGFloat {
        get { return layer.borderWidth }
        set {
            layoutMargins = UIEdgeInsets(top: newValue, left: newValue, bottom: newValue, right: newValue)
            layer.borderWidth = newValue
        }
    }
    / / / font
    var font: UIFont {
        get { return toplabel.font }
        set { [toplabel, bottomLabel].forEach { $0.font = newValue } }
    }
}
Copy the code

At this point, the entire roller effect is complete.

Mount view

Instantiate the view you just customized in FirstViewController, set the font, border, background color, Contents, etc., and remember to set isUserInteractionEnabled to false so that the original event response will not be affected.

 override func viewDidLoad(a) {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        
        tableView.delegate = self
        tableView.dataSource = self
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "DefaultCell")
        tableView.rowHeight = 44

        wheelView = WheelView(frame: CGRect.zero)
        wheelView.font = UIFont.systemFont(ofSize: 15, weight: .bold)
        wheelView.borderWidth = 1
        wheelView.backgroundColor = UIColor.white
        wheelView.contents = data
        wheelView.isUserInteractionEnabled = false
}
Copy the code

To mount the view onto the original icon, add code at the bottom of the viewDidLoad() method:

Update April 23: The way custom views replace tabbar ICONS is now more common

 override func viewDidLoad(a){...var parentController = self.parent
    while! (parentControlleris UITabBarController) {
        ifparentController? .parent ==nil { return} parentController = parentController? .parent }let tabbarControlelr = parentController as! UITabBarController
    
    var controllerIndex = -1
    findControllerIndexLoop: for (i, child) in tabbarControlelr.children.enumerated() {
        var stack = [child]
        while stack.count > 0 {
            let count = stack.count
            for j in stride(from: 0, to: count, by: 1) {
                if stack[j] is Self {
                    controllerIndex = i
                    break findControllerIndexLoop
                }
                for vc in stack[j].children {
                    stack.append(vc)
                }
            }
            for _ in 1.count {
                stack.remove(at: 0)}}}if controllerIndex == -1 { return }
    var tabBarButtons = tabbarControlelr.tabBar.subviews.filter({
        type(of: $0).description().isEqual("UITabBarButton")})guard! tabBarButtons.isEmptyelse { return }
    let tabBarButton = tabBarButtons[controllerIndex]
    let swappableImageViews = tabBarButton.subviews.filter({
        type(of: $0).description().isEqual("UITabBarSwappableImageView")})guard! swappableImageViews.isEmptyelse { return }
    let swappableImageView = swappableImageViews.first!
    tabBarButton.addSubview(wheelView)
    swappableImageView.isHidden = true
    NSLayoutConstraint.activate([
        wheelView.widthAnchor.constraint(equalToConstant: 25),
        wheelView.heightAnchor.constraint(equalToConstant: 25),
        wheelView.centerXAnchor.constraint(equalTo: swappableImageView.centerXAnchor),
        wheelView.centerYAnchor.constraint(equalTo: swappableImageView.centerYAnchor)
    ])
 }
Copy the code

The purpose of this code is eventually found within the corresponding label UITabBarButton type for UITabBarSwappableImageView view and replace it. It looks pretty complicated, but it does everything it can to avoid unexpected situations that cause exceptions to the program. As long as UIkit does not change after type UITabBarButton and UITabBarSwappableImageView, and their inclusion relation, basically there will be no accident, cause most custom view mount will not come up. Another benefit is that the FirstViewController doesn’t have to worry about the number of tags it’s added to the TabBarController. It’s not perfect in general, but there doesn’t seem to be a better way, right?

You can actually peel the above code off and put it on the default implementation of the Protocol called TabbarInteractable. The required ViewController simply declares compliance with the protocol and then calls a method in the viewDidLoad method to implement the entire replacement process.

We just have one last step, and we know that UITableView is a subclass of UIScrollView. As it scrolls, FirsViewController, as the delegate of UITableView, also receives a call from scrollViewDidScroll, so updating the progress of the scroll in this method is appropriate:

// MARK: UITableViewDelegate
extension FirstViewController: UITableViewDelegate {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        // How to calculate 'progress' depends on your needs, here is to display the bottom two numbers in the current visible area of 'tableView'.
        let progress = Float((scrollView.contentOffset.y + tableView.bounds.height - tableView.rowHeight) / scrollView.contentSize.height)
        wheelView.progress = progress
    }
}
Copy the code

Take a look at the project and you will get the effect of the beginning of the article.

【全文完】