自定義UITabBarController、UITabBar和UIButton
通常情況下,在實際開發過程中經常需要自定義UITabBarController,並且很有可能還涉及到自定義UITabBar和UIButton的情況。就以閒魚為例,我們嘗試著模仿一下它。
為了更好的演示和說明,整個演示專案都將使用純程式碼來搭建。所以,來到AppDelegate檔案中,實現以下程式碼:
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
// 建立視窗並設定其frame
window = UIWindow(frame: UIScreen.main.bounds)
// 設定視窗的背景顏色(便於除錯)
window?.backgroundColor = .white
// 設定視窗的根控制器
window?.rootViewController = MainViewController()
// 顯示視窗
window?.makeKeyAndVisible()
return true
}
因為我們的主要目的是演示自定義相關控制元件,所以在專案搭建的過程中會省略一些細節,不過,我會盡可能的保證邏輯清晰和完整。上述程式碼完成之後,執行程式就可以看到下面有一個TabBar了:
來到MainViewController這個檔案中新增子控制器。MainViewController是我們自己新建的檔案,它繼承自UITabBarController。通常情況下,為了保證程式碼邏輯的清晰,同時也便於後續的閱讀和維護,我們都會將具有某種功能的程式碼抽取到一個方法中,我們這裡也採用這種方式:
class MainViewController: UITabBarController {
override func viewDidLoad() {
super.viewDidLoad()
// 統一設定UI介面
setupUI()
}
}
// MARK: - 設定UI介面
extension MainViewController {
/// 統一設定UI介面
fileprivate func setupUI() {
// 一次性新增所有的子控制器
addChildViewControllers()
}
/// 一次性新增所有的子控制器
private func addChildViewControllers() {
// 分別新增各子控制器
addChildViewController(HomeViewController(), title: "首頁", imageName: "home")
addChildViewController(FishpondViewController(), title: "魚塘", imageName: "fishpond")
// addChildViewController(UIViewController()) // 佔位控制器
addChildViewController(MessageViewController(), title: "訊息", imageName: "message")
addChildViewController(AccountViewController(), title: "我的", imageName: "account")
}
/// 新增單個子控制器
private func addChildViewController(_ childController: UIViewController, title: String, imageName: String) {
// 設定子控制器的標題
childController.title = title
// 設定子控制器tabBarItem的圖片
childController.tabBarItem.image = UIImage(named: imageName + "_normal")
childController.tabBarItem.selectedImage = UIImage(named: imageName + "_highlight")
// 將子控制器包裝成導航控制器
let nav = UINavigationController(rootViewController: childController)
// 將導航控制器新增到父控制器中
addChildViewController(nav)
}
}
想必你可能注意到了,在新增子控制器的過程中,我們有一行程式碼(設定佔位控制器的那一行)註釋掉了。其實這也是一種思路,就是碰到tabBar中間有一個特殊按鈕時,我們可以先搞一個佔位控制器,然後在這個佔位控制器的tabBarItem上覆蓋一個按鈕以實現我們的目的,這種方式的好處是省事,不好的地方是浪費效能。雖然總體影響幾乎可以忽略不計,但畢竟還有一個閒置的控制器呢!所以,我們這裡不用這種方式。我們採用的方式是,重新調整其它tabBarItem的位置,然後在正中間新增一個按鈕。後面會詳細講,先來看一下程式執行的效果:
圖片被渲染得非常的醜,而且tabBar上面的標題大小和顏色也不是我們想要的。為此,需要進行一下額外的設定。來到新增單個子控制器的方法中實現以下程式碼:
/// 新增單個子控制器
private func addChildViewController(_ childController: UIViewController, title: String, imageName: String) {
// 設定子控制器的標題
childController.title = title
// 設定子控制器tabBarItem的圖片
childController.tabBarItem.image = UIImage(named: imageName + "_normal")?.withRenderingMode(.alwaysOriginal)
childController.tabBarItem.selectedImage = UIImage(named: imageName + "_highlight")?.withRenderingMode(.alwaysOriginal)
// 設定子控制器tabBarItem字型的顏色
var textColor: [String: Any] = Dictionary()
textColor[NSForegroundColorAttributeName] = UIColor.black
childController.tabBarItem.setTitleTextAttributes(textColor, for: .selected)
// 設定子控制器tabBarItem字型的大小
var textFont: [String: Any] = Dictionary()
textFont[NSFontAttributeName] = UIFont.systemFont(ofSize: 9)
childController.tabBarItem.setTitleTextAttributes(textFont, for: .normal)
// 將子控制器包裝成導航控制器
let nav = UINavigationController(rootViewController: childController)
// 將導航控制器新增到父控制器中
addChildViewController(nav)
}
不讓編譯器對圖片進行預設的渲染,除了使用純程式碼之外,最簡單的方式是使用編譯器特性,這裡就不做演示。執行程式看一下效果:
接下來的工作就是要在tabBar正中間新增一個釋出按鈕,為此,我們必須自定義UITabBar。自定義的思路有兩種,一種是部分自定義,也就是還需要藉助系統自帶的TabBar,我們只是對它進行相應的改造,使其符合我們預期的需求;另一種方式是完全自定義,也就是幹掉系統自帶的UITabBar,我們自己動手寫一個。在我們這個專案中,由於只是要往中間新增一個釋出按鈕,其它東西不變,沒必要完全自己動手寫,所以我們採用部分自定這種方式。
新建一個繼承自UITabBar的TabBar類(因為Swift有名稱空間,所以可以不用像Objective-C那樣寫類字首),然後回到MainViewController檔案中,在統一設定UI介面的方法中實現如下程式碼:
/// 統一設定UI介面
fileprivate func setupUI() {
// 一次性新增所有的子控制器
addChildViewControllers()
// 自定義tabBar
let tabBar = TabBar()
self.setValue(tabBar, forKeyPath: "tabBar")
}
上面的程式碼中用到了KVC的基礎知識,由於KVC的東西展開還是比較多的,完全可以單獨搞一個專題,所以這裡就不做展開了。來到TabBar這個類中,實現init(frame: )這個方法,並且在它裡面設定tabBar的UI介面:
class TabBar: UITabBar {
override init(frame: CGRect) {
super.init(frame: frame)
// 統一設定UI介面
setupUI()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// 設定UI介面
extension TabBar {
/// 統一設定UI介面
fileprivate func setupUI() {
// 設定tabBar的背景圖片
backgroundImage = UIImage(named: "tabbar_bg")
}
}
上面的程式碼主要是給tabBar設定背景圖片,這裡就不放效果圖了,我們直接開始下一個專案,新建一個Swift檔案,給UIButton新增一個擴充套件:
extension UIButton {
/// 根據給定的圖片自定義按鈕
/// - 引數imageName: 表示普通狀態下按鈕的圖片
/// - 引數backgroundImageName: 表示高亮狀態下按鈕的圖片
convenience init(imageName: String, backgroundImageName: String = "") {
self.init()
// 設定按鈕的圖片
setImage(UIImage(named: imageName), for: .normal)
// 設定按鈕的背景圖片
setBackgroundImage(UIImage(named: backgroundImageName), for: .highlighted)
// 設定按鈕的尺寸
//sizeToFit()
}
}
上述程式碼的主要目的是方便我們根據給定的圖片來建立相應的按鈕。另外,你可能也注意到了,sizeToFit()這行程式碼被我們給註釋掉了,它是用來設定按鈕的尺寸的,也就是圖片有多大,按鈕的尺寸就有多大,一般而言是需要這句程式碼的。但是,在我們這個專案中,中間釋出按鈕的圖片太大,需要我們對其進行適當的調整,所以這裡就不需要這句程式碼了,按鈕的frame我們會在外面適當的地方單獨設定。回到TabBar這個類中,對中間的釋出按鈕進行懶載入:
// MARK: - 懶載入屬性
/// 中間的釋出按鈕
fileprivate lazy var postButton: UIButton = {
// 設定中間釋出按鈕的背景圖片
let postButton = UIButton(imageName: "post_normal")
return postButton
}()
因為沒有高亮狀態下的背景圖片,所以我們這裡可以不傳。需要說明一下,在自定義方法的過程中,如果某一個或者多個引數有預設值,那麼在方法呼叫的過程中,系統會幫我們生成同一個系列的多個方法,以我們上面自定義UIButton的便利建構函式為例,由於backgroundImageName這個引數有預設值,所以我們會得到init(imageName: , backgroundImageName: )
和init(imageName: )
這兩個方法。
接下來的任務就是添加發布按鈕。來到設定UI介面的Extension程式碼塊中,將釋出按鈕新增上去,並且監聽按鈕的點選。就像前面所說的一樣,為了保證邏輯清晰和程式碼的可讀性,最好是一個功能一個方法或者程式碼塊:
// MARK: - 設定UI介面
extension TabBar {
/// 統一設定UI介面
fileprivate func setupUI() {
// 設定tabBar的背景圖片
backgroundImage = UIImage(named: "tabbar_bg")
// 新增post按鈕
setupPostButton()
}
/// 新增中間的釋出按鈕
private func setupPostButton() {
// 將按鈕新增到tabBar上面
addSubview(postButton)
// 設定釋出按鈕的文字
postButton.setTitle("釋出", for: .normal)
// 設定釋出按鈕文字的顏色
postButton.setTitleColor(.darkGray, for: .normal)
// 設定釋出按鈕文字字型大小
postButton.titleLabel?.font = UIFont.systemFont(ofSize: 9)
// 設定按鈕文字居中顯示
postButton.titleLabel?.textAlignment = .center
// 監聽中間釋出按鈕的點選
postButton.addTarget(self, action: #selector(TabBar.postButtonClick), for: .touchUpInside)
}
}
// MARK: - 監聽按鈕的點選
extension TabBar {
/// 監聽中間釋出按鈕的點選
@objc fileprivate func postButtonClick() {
print("postButtonClick")
}
}
至此,釋出按鈕已經新增上去了。但是,如果此時執行程式,你還不能看見它,因為我們沒有給它設定frame。不光如此,系統自帶的按鈕也不能滿足我們的需求。因為系統自帶的按鈕左邊是圖片,右邊是文字,而我們需要的是上面是圖片,下面是文字,為此,我們要自定義UIButton。新建一個繼承自UIButton的Button類,然後實現如下程式碼:
class Button: UIButton {
override init(frame: CGRect) {
super.init(frame: frame)
// 統一設定UI介面
setupUI()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// 設定釋出按鈕中imageView的frame
override func imageRect(forContentRect contentRect: CGRect) -> CGRect {
return CGRect(x: 0, y: 0, width: self.bounds.width, height: self.bounds.width - 2)
}
// 設定釋出按鈕中label的frame
override func titleRect(forContentRect contentRect: CGRect) -> CGRect {
return CGRect(x: 0, y: self.bounds.height, width: self.bounds.width, height: self.bounds.height - self.bounds.width)
}
}
// MARK: - 設定UI介面
extension Button {
/// 統一設定UI介面
fileprivate func setupUI() {
// 去掉按鈕點選時置灰效果
adjustsImageWhenHighlighted = false
// 設定按鈕的frame
frame = CGRect(x: 0, y: 0, width: 52, height: 59)
}
}
到這裡還沒完,需要把我們自定的Button給用上。來到TabBar這個類中,在懶載入postButton的程式碼中,將按鈕的型別替換成我們自己的:
// MARK: - 懶載入屬性
/// 中間的釋出按鈕
fileprivate lazy var postButton: Button = {
// 設定中間釋出按鈕的背景圖片
let postButton = Button(imageName: "post_normal")
return postButton
}()
最後再來到設定UI介面的程式碼塊中,重寫layoutSubviews()
方法,在它裡面重新佈局TabBar子控制元件的frame:
/// 調整子控制元件的位置,或者設定子空間的frame
override func layoutSubviews() {
super.layoutSubviews()
// 用於儲存按鈕
var tabBarButtonArr = [Any]()
// 遍歷tabBar的子控制元件
for subView in self.subviews {
// 將所有UITabBarButton存放到陣列中
if subView.isKind(of: NSClassFromString("UITabBarButton")!) {
tabBarButtonArr.append(subView)
}
}
// 獲取tabBar的寬度和高度
let tabBarWidth: CGFloat = self.bounds.size.width
let tabBarHeight: CGFloat = self.bounds.size.height
// 獲取釋出按鈕的寬度和高度
let postButtonWidth: CGFloat = postButton.frame.width
// 重新佈局postButton的位置
postButton.center = CGPoint(x: tabBarWidth * 0.5, y: tabBarHeight * 0.2)
// 計算tabBarButton的寬度
let tabBarButtonWidth: CGFloat = (tabBarWidth - postButtonWidth) / CGFloat(tabBarButtonArr.count)
// 遍歷tabBarButtonArr,取出裡面的tabBarButton和與之對應的index
for (index, subview) in tabBarButtonArr.enumerated() {
// 取出subview的frame
var subviewFrame = (subview as! UIView).frame
if index >= tabBarButtonArr.count / 2 {
// 設定下標為2和3的tabBarButton的x值
subviewFrame.origin.x = CGFloat(index) * tabBarButtonWidth + postButtonWidth
} else {
// 設定下標為0和1的tabBarButton的x值
subviewFrame.origin.x = CGFloat(index) * tabBarButtonWidth
}
// 設定tabBarButton的寬度
subviewFrame.size.width = tabBarButtonWidth
// 重寫設定tabBarButton的frame
(subview as! UIView).frame = subviewFrame
}
// 將釋出按鈕移動到最上面
bringSubview(toFront: postButton)
}
釋出按鈕中imageView和label的相對位置可以自己去調,這個比較簡單,我就不做詳細說明和演示了。此時執行程式看一下,應該可以看到正中間的釋出按鈕了:
需要說明一下,點選中間的釋出按鈕其實是有反應的,只不過我設定了按鈕的adjustsImageWhenHighlighted
為false,也就是禁用了按鈕點選時自動變灰的效果。除此之外,還有一個功能需要完善一下,就是釋出按鈕上半部分超出了父控制元件TabBar,我們在點選它時會沒有反應,為此,只需要在TabBar中重寫hitTest(_ : with: )這個方法就可以了:
/// 重寫hitTest(_ : , with : )方法,讓超出tabBar部分也能響應事件
/// - 如果父控制元件不能接收觸控事件,那麼子控制元件就不可能接收觸控事件
/// - 返回的是誰,誰就是最適合處理事件的View
/// - hitTest(_ : , with : )方法會被呼叫兩次
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// 呼叫父控制元件的hitTest(_ : , with : )方法
var result = super.hitTest(point, with: event)
// 如果控制元件不可互動、控制元件被隱藏,或者控制元件是透明的,則表示不能處理事件(控制元件不互動的三種情況)
if self.isUserInteractionEnabled == false || self.isHidden == true || self.alpha <= 0.01 {
return nil
}
// 當result可以處理事件時,返回result
if (result != nil) { return result }
// 遍歷tabBar的子空間
for subview in subviews {
// 把這個座標從tabBar的座標系轉為postButton的座標系
let subPoint: CGPoint = subview.convert(point, from: self)
// 呼叫子控制元件,也就是postButton的hitTest(_ : , with : )方法
result = subview.hitTest(subPoint, with: event)
// 如果事件發生在subview裡就返回result
if (result != nil) {
return result
}
}
return nil
}
執行程式,再點選中間釋出按鈕超出TabBar的上半部分,我們就可以看到程式的響應了。詳細程式碼參見CustomTabBarDemo。