Tab Bar 的圖標原來還能夠這樣玩

示例代碼下載git

背景

框架自帶的 Tab Bar 相信你們已經熟悉得不能再熟悉了,通常使用的時候不過是設置兩個圖標表明選中和未選中兩種狀態,不免有一些平淡。後來不少控件就在標籤選中時進行一些比較抓眼球的動畫,不過我以爲大部分都是爲了動畫而動畫。直到後來我看到Outlook客戶端的動畫時,我才意識到原來還能夠跟用戶的交互結合在一塊兒。github

圖1 標籤圖標跟隨手勢進行不一樣的動畫swift

有意思吧,不過本文並非要仿製個如出一轍的出來,會有稍微變化:bash

圖2 本文完成的最終效果app

實現分析

寫代碼以前,咱先討論下實現的方法,相信你已經猜到標籤頁的圖標顯然已經不是圖片,而是一個自定義的UIView。將一個視圖掛載到本來圖標的位置並非一件難事,稍微有些複雜的是數字滾輪效果的實現,別看它數字不停地在滾動,仔細看其實最多顯示2種數字,也就說只要2個Label就夠了。框架

基於篇幅,文章不會涉及右側的時鐘效果,感興趣請直接參考源碼。ide

數字滾輪

打開項目TabBarInteraction,新建文件WheelView.swift,它是UIView的子類。首先設置好初始化函數:函數

class WheelView: UIView {
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setupView()
    }
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
    }
}
複製代碼

接着建立兩個Label實例,表明滾輪中的上下兩個Labeloop

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
}
複製代碼

如今來完成setupView()方法,在這方法中將上述兩個Label添加到視圖中,而後設置約束將它們的四邊都與layoutMarginsGuide對齊。字體

private func setupView() {
    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)
        ])
    }
}
複製代碼

有人可能會問如今這樣兩個Label不是重疊的狀態嗎?不着急,接下來咱們會根據參數動態地調整它們的大小和位置。 添加兩個實例變量progresscontents,分別表示滾動的整體進度和顯示的所有內容。

var progress: Float = 0.0
var contents = [String]()
複製代碼

咱們接下來要根據這兩個變量計算出當前兩個Label顯示的內容以及它們的縮放位置。這些計算都在progressdidSet裏完成:

var progress: Float = 0.0 {
    didSet {
        progress = min(max(progress, 0.0), 1.0) 
        guard contents.count > 0 else { return }
        
        /** 根據 progress 和 contents 計算出上下兩個 label 顯示的內容以及 label 的壓縮程度和位置 * * Example: * progress = 0.4, contents = ["A","B","C","D"] * * 1)計算兩個label顯示的內容 * topIndex = 4 * 0.4 = 1.6, topLabel.text = contents[1] = "B" * bottomIndex = 1.6 + 1 = 2.6, bottomLabel.text = contents[2] = "C" * * 2) 計算兩個label如何壓縮和位置調整,這是實現滾輪效果的原理 * indexOffset = 1.6 % 1 = 0.6 * halfHeight = bounds.height / 2 * ┌─────────────┐ ┌─────────────┐ * |┌───────────┐| scaleY | | * || || 1-0.6=0.4 | | translationY * || topLabel || ----------> |┌─ topLabel─┐| ------------------┐ * || || |└───────────┘| -halfHeight * 0.6 | ┌─────────────┐ * |└───────────┘| | | | |┌─ toplabel─┐| * └─────────────┘ └─────────────┘ | |└───────────┘| * | -> |┌───────────┐| * ┌─────────────┐ ┌─────────────┐ | ||bottomLabel|| * |┌───────────┐| scaleY | | | |└───────────┘| * || || 0.6 |┌───────────┐| translationY | └─────────────┘ * ||bottomLabel|| ----------> ||bottomLabel|| ------------------┘ * || || |└───────────┘| halfHeight * 0.4 * |└───────────┘| | | * └─────────────┘ └─────────────┘ * * 能夠想象出,當 indexOffset 從 0..<1 過程當中, * topLabel 從滿視圖越縮越小至0,而 bottomLabel恰好相反越變越大至滿視圖,即造成一次完整的滾動 */
        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))))
    }
}
複製代碼

最後咱們還要向外公開一些樣式進行自定義:

extension WheelView {
    /// 前景色變化事件
    override func tintColorDidChange() {
        [toplabel, bottomLabel].forEach { $0.textColor = tintColor }
        layer.borderColor = tintColor.cgColor
    }
    /// 背景色
    override var backgroundColor: UIColor? {
        get { return toplabel.backgroundColor }
        set { [toplabel, bottomLabel].forEach { $0.backgroundColor = newValue } }
    }
    /// 邊框寬度
    var borderWidth: CGFloat {
        get { return layer.borderWidth }
        set {
            layoutMargins = UIEdgeInsets(top: newValue, left: newValue, bottom: newValue, right: newValue)
            layer.borderWidth = newValue
        }
    }
    /// 字體
    var font: UIFont {
        get { return toplabel.font }
        set { [toplabel, bottomLabel].forEach { $0.font = newValue } }
    }
}
複製代碼

至此,整個滾輪效果已經完成。

掛載視圖

FirstViewController中實例化剛纔自定義的視圖,設置好字體、邊框、背景色、Contents等內容,別忘了isUserInteractionEnabled設置爲false,這樣就不會影響原先的事件響應。

override func viewDidLoad() {
        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
}
複製代碼

而後要把視圖掛載到原先的圖標上,viewDidLoad()方法底部新增代碼:

4月23日更新:自定義視圖替換 tabbar 圖標的方法如今更通用了

override func viewDidLoad() {
    ...
    var parentController = self.parent
    while !(parentController is UITabBarController) {
        if parentController?.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.isEmpty else { return }
    let tabBarButton = tabBarButtons[controllerIndex]
    let swappableImageViews = tabBarButton.subviews.filter({
        type(of: $0).description().isEqual("UITabBarSwappableImageView")
    })
    guard !swappableImageViews.isEmpty else { 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)
    ])
 }
複製代碼

上述代碼的目的是最終找到對應標籤UITabBarButton內類型爲UITabBarSwappableImageView的視圖並替換它。看上去至關複雜,可是它儘量地避免出現意外狀況致使程序異常。只要之後UIkit不更改類型UITabBarButtonUITabBarSwappableImageView,以及他們的包含關係,程序基本不會出現意外,最多致使自定義的視圖掛載不上去而已。另一個好處是FirstViewController不用去擔憂它被添加到TabBarController中的第幾個標籤上。整體來講這個方法並不完美,但目前彷佛也沒有更好的方法?

實際上還能夠將上面的代碼剝離出來,放到名爲TabbarInteractableprotocol的默認實現上。有須要的ViewController只要宣佈遵照該協議,而後在viewDidLoad方法中調用一個方法便可實現整個替換過程。

只剩下最後一步了,咱們知道UITableViewUIScrollView的子類。在它滾動的時候,FirsViewController做爲UITableViewdelegate,一樣會收到scrollViewDidScroll方法的調用,因此在這個方法裏更新滾動的進度再合適不過了:

// MARK: UITableViewDelegate
extension FirstViewController: UITableViewDelegate {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        //`progress`怎麼計算取決於你需求,這裏的是爲了把`tableview`當前可見區域最底部的2個數字給顯示出來。
        let progress = Float((scrollView.contentOffset.y + tableView.bounds.height - tableView.rowHeight) / scrollView.contentSize.height)
        wheelView.progress = progress
    }
}
複製代碼

把項目跑起來看看吧,你會獲得文章開頭的效果。

【全文完】

相關文章
相關標籤/搜索