來自:知識小集(ID:iOS-Tips) 連結:https://juejin.im/post/5c9fa72d6fb9a05e6835c8a6
背景
框架自帶的 Tab Bar 相信大家已經熟悉得不能再熟悉了,一般使用的時候不過是設定兩個圖示代表選中和未選中兩種狀態,難免有一些平淡。後來很多控制元件就在標簽選中時進行一些比較抓眼球的動畫,不過我覺得大部分都是為了動畫而動畫。直到後來我看到 Outlook
客戶端的動畫時,我才意識到原來還可以跟使用者的互動結合在一起。
有意思吧,不過本文並不是要仿製個一模一樣的出來,會有稍微變化:
實現分析
寫程式碼之前,咱先討論下實現的方法,相信你已經猜到標簽頁的圖示顯然已經不是圖片,而是一個自定義的UIView。將一個檢視掛載到原本圖示的位置並不是一件難事,稍微有些複雜的是數字滾輪效果的實現,別看它數字不停地在滾動,仔細看其實最多顯示2種數字,也就說只要2個Label就夠了。
基於篇幅,文章不會涉及右側的時鐘效果,感興趣請直接參考原始碼。
數字滾輪
開啟專案 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實體,代表滾輪中的上下兩個Label:
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 不是重疊的狀態嗎?不著急,接下來我們會根據引數動態地調整它們的大小和位置。新增兩個實體變數 progress
和 contents
,分別表示滾動的總體進度和顯示的全部內容。
var progress: Float = 0.0
var contents = [String]()
我們接下來要根據這兩個變數計算出當前兩個Label顯示的內容以及它們的縮放位置。這些計算都在 progress
的 didSet
裡完成:
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.0 遞增到 0.999 過程中,
* 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()
方法底部新增程式碼:
override func viewDidLoad() {
…
guard let parentController = self.parent as? UITabBarController else { return }
let controllerIndex = parentController.children.firstIndex(of: self)!
var tabBarButtons = parentController.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 不更改型別 UITabBarButton
和 UITabBarSwappableImageView
,以及他們的包含關係,程式基本不會出現意外,最多導致自定義的檢視掛載不上去而已。另外一個好處是 FirstViewController
不用去擔心它被新增到 TabBarController
中的第幾個標簽上。總體來說這個方法並不完美,但目前似乎也沒有更好的方法?
實際上還可以將上面的程式碼剝離出來,放到名為TabbarInteractable的protocol的預設實現上。有需要的ViewController只要宣佈遵守該協議,然後在viewDidLoad方法中呼叫一個方法即可實現整個替換過程。
只剩下最後一步了,我們知道 UITableView
是 UIScrollView
的子類。在它滾動的時候,FirsViewController
作為 UITableView
的delegate,同樣會收到 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
}
}
把專案跑起來看看吧,你會得到文章開頭的效果。
朋友會在“發現-看一看”看到你“在看”的內容