通常情况下,在实际开发过程中经常需要自定义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。