自定义UITabBarController、UITabBar和UIButton

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

闲鱼UITabBarController示例.jpg

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

添加tabBar.png

  来到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的位置,然后在正中间添加一个按钮。后面会详细讲,先来看一下程序运行的效果:

添加子控制器.gif

  图片被渲染得非常的丑,而且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)
}

  不让编译器对图片进行默认的渲染,除了使用纯代码之外,最简单的方式是使用编译器特性,这里就不做演示。运行程序看一下效果:

还原图片本来的样子.gif

  接下来的工作就是要在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的相对位置可以自己去调,这个比较简单,我就不做详细说明和演示了。此时运行程序看一下,应该可以看到正中间的发布按钮了:

添加中间的发布按钮.gif

  需要说明一下,点击中间的发布按钮其实是有反应的,只不过我设置了按钮的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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容

  • 前言 很多时候,系统原生的 UITabBar 并不能满足我们的需求,譬如我们想要给图标做动态的改变,或者比较炫一点...
    四月_Hsu阅读 4,994评论 1 6
  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,016评论 4 62
  • 第二章,全息北京交通镜像 “好的,先生,我已接入所有数据接口及视频”手机影子已经打开了所有的镜像系统接口,瞬间在北...
    众心无相阅读 181评论 1 2
  • 一杯霁夜茶 清风不还家
    512fe909648f阅读 224评论 0 0
  • 讲授李白诗后赠诸弟子 千载华章溢酒香, 金樽珍馐送客尝。 但君若解其中味, 不枉李白醉一场!
    相忘于江湖的刘老师阅读 349评论 0 4