自定义UITabBarController、UITabBar和UIButton

标签: 自定义TabBar

  通常情况下,在实际开发过程中经常需要自定义UITabBarController,并且很有可能还涉及到自定义UITabBar和UIButton的情况。就以闲鱼为例,我们尝试着模仿一下它。

闲鱼TabBar示例

  为了更好的演示和说明,整个演示项目都将使用纯代码来搭建。所以,来到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了:

TabBarController

  来到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

版权声明:本文为Enrica_Shi原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/Enrica_Shi/article/details/78231956