iOS 中使用 UITableView 实现多个二级列表与共享组标题-LMLPHP

引言

在现代应用程序中,优美且高效的用户界面设计是吸引用户的关键。特别是在显示大量数据时,如何组织和展示信息变得尤为重要。UITableView 和 UIScrollView 是 iOS 开发中常用的控件,它们可以帮助我们实现复杂的列表结构。然而,当面对多个二级列表并希望它们共享一个组标题时,挑战也随之而来。

在这篇博客中,我们将探讨如何利用 UITableView 来实现多个二级列表共享组标题的效果。通过一些巧妙的设计和编程技巧,你将能够创建一个既美观又实用的用户界面,为用户提供更好的体验。

经典案例 - 分析

iOS 中使用 UITableView 实现多个二级列表与共享组标题-LMLPHP

一个比较常见的案例,在娱乐社交类APP的个人页中,通常它会有一个简略的用户信息为头部视图,然后下面跟随多个列表来展示用户的其它信息。

不仅整个页面可以滑动,每个子页面也需要可以滑动。

这就需要我们自己来计算每个列表的滑动时机了。但还有更关键的一步,就是需要让两个UIScrollView同时响应我们的上滑下滑。

经典案例 - 实现

我们把整个功能的实现过程分为三个部分:

  1. 创建多级列表。
  2. 实现嵌套列表同时响应滑动手势。
  3. 通过计算控制哪个列表滑动。

1.创建多级列表

首先我们来讨论一下页面的整体结构,由于多个列表公用一个顶部视图,所以顶部视图理应显示在父图层上。而在我们上下滑动列表时顶部的视图也跟随着一起滑动,那么就意味着我们的父图层需要有一个可以滑动的UIScrollView或者是它的子类。

这里我们直接采用UITableView来实现,因为UITableView自带的组标题悬停的功能就可以为我们减少很多计算的工作。

而子视图则根据需要来选取不同的视图即可,为了使案例显得更简单易懂,我们的子视图控制器的滑动视图也采用UITableView的方式实现,这样即使有多个子视图就可以集成自同一个基类来处理决定谁来滑动的计算工作。

假设我们有三个子视图控制,那么文件结构如下:

iOS 中使用 UITableView 实现多个二级列表与共享组标题-LMLPHP

对比页面来看的话:

iOS 中使用 UITableView 实现多个二级列表与共享组标题-LMLPHP

  • PHProfileCell:为红色区域部分,显示用户的基本信息,当然我们也可以采用UITableView的头视图的方式来实现。
  • PHProfileSectionHeaderView:绿色区域,显示底部子视图控制器的切换按钮。
  • PHProfileBottomCell:蓝色区域专门用来承载子视图页面控制器。

PHProfileViewController 具体代码如下:

    /// 列表
    private let tableView = UITableView(frame: .zero, style: .plain)

    override func viewDidLoad() {
        super.viewDidLoad()
        addCustomNavigationBar()
        self.customNavigationBar.title = "个人中心"
        addTableView()
    }
    
    func addTableView() {
        view.addSubview(tableView)
        tableView.delegate = self
        tableView.dataSource = self
        tableView.register(PHProfileCell.self, forCellReuseIdentifier: "PHProfileCell")
        tableView.register(PHProfileBottomCell.self, forCellReuseIdentifier: "PHProfileBottomCell")
        tableView.register(PHProfileSectionHeaderView.self, forHeaderFooterViewReuseIdentifier: "PHProfileSectionHeaderView")
        tableView.snp.makeConstraints { make in
            make.top.equalTo(cs_navigationBarHeight)
            make.leading.trailing.bottom.equalToSuperview()
        }
    }
    
    func numberOfSections(in tableView: UITableView) -> Int {
        return 2
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 1
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        if indexPath.section == 0 {
           return tableViewProfileCell(tableView, cellForRowAt: indexPath)
        } else {
            return tableViewBottomCell(tableView, cellForRowAt: indexPath)
        }

    }
    
    /// 用户信息
    private func tableViewProfileCell(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "PHProfileCell", for: indexPath)
        return cell
    }
    
    /// 底部cell
    private func tableViewBottomCell(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "PHProfileBottomCell", for: indexPath)
        return cell
    }
    
    ///
    func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
        if section == 1 {
            let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: "PHProfileSectionHeaderView") as! PHProfileSectionHeaderView
            return headerView
        }
        return nil
    }
    
    func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
        if section == 1 {
            return 55.0
        }
        return 0.0
    }

PHProfileSectionHeaderView的主要功能就是提供三个按钮,点击时可以通知主页面来切换子视图控制器代码如下:

    /// 个人页
    let personButton = UIButton(type: .custom)
    /// 动态页
    let dynamicButton = UIButton(type: .custom)
    /// 相册页
    let albumButton = UIButton(type: .custom)
    // 颜色滑块
    let colorView = CLGradientView(startColor: UIColor(hexStr: "00FFFF"), endColor: UIColor(hexStr: "FFFF00"))
    /// 当前按钮
    var selectedButton:UIButton!
    
    override init(reuseIdentifier: String?) {
        super.init(reuseIdentifier: reuseIdentifier)
        setupView()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func setupView() {
        let buttons = [personButton, dynamicButton, albumButton]
        let titles = ["Person", "Moments", "Album"]
        let offsetx = 16.0
        var lastButton:UIButton?
        for (index, button) in buttons.enumerated() {
            button.setTitle(titles[index], for: .normal)
            button.setTitleColor(.black.withAlphaComponent(0.4), for: .normal)
            button.setTitleColor(.black, for: .selected)
            button.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .medium)
            button.tag = 100 + index
            self.addSubview(button)
            button.snp.makeConstraints { make in
                if let lastButton = lastButton {
                    make.leading.equalTo(lastButton.snp.trailing).offset(18.0)
                } else {
                    make.leading.equalToSuperview().offset(offsetx)
                }
                make.centerY.equalToSuperview()
                make.height.equalToSuperview()
            }
            lastButton = button
            button.addTarget(self, action: #selector(buttonOnclick(button:)), for: .touchUpInside)
        }
        
        selectedButton = personButton
        selectedButton?.isSelected = true
        selectedButton?.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
        
        // 滑块
        self.contentView.backgroundColor = .white
        colorView.layer.masksToBounds = true
        colorView.layer.cornerRadius = 1.5
        self.addSubview(colorView)
    }
    
    func setLayout() {
        colorView.snp.makeConstraints { make in
            make.centerX.equalTo(selectedButton)
            make.top.equalTo(selectedButton.snp.bottom).offset(-4.0)
            make.width.equalTo(16.0)
            make.height.equalTo(3.0)
        }
    }

    @objc func buttonOnclick(button:UIButton) {
        if let selectedButton = selectedButton {
            selectedButton.isSelected = false
            selectedButton.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .medium)
        }
        button.isSelected = true
        button.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
        self.selectedButton = button
        colorView.snp.remakeConstraints { make in
            make.centerX.equalTo(button)
            make.top.equalTo(button.snp.bottom).offset(-4.0)
            make.width.equalTo(16.0)
            make.height.equalTo(3.0)

        }
        UIView.animate(withDuration: 0.2) {
            self.contentView.layoutIfNeeded()
        }
        //通知修改 子页面
        NotificationCenter.default.post(name: kCSNotificationPersonalGroupTitleClick, object: button.tag - 100)
    }

PHProfileBottomCell主要用来承载子页面控制器,但是由于页面可以滑动,所以需要有一个横向滚动的UIScrollView代码如下:

    private let mainScrollView = UIScrollView()
    /// 个人中心
    private let personViewController = PHProfilePersonViewController()
    /// 动态
    private let dynamicViewController = PHProfileDynamicViewController()
    /// 相册
    private let albumViewController = PHProfileAlbumViewController()

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        addMainScrollView()
        addPersonViewController()
        addDynamicViewController()
        addAlbumViewController()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func addMainScrollView() {
        contentView.addSubview(mainScrollView)
        mainScrollView.backgroundColor = .white
        mainScrollView.isPagingEnabled = true
        mainScrollView.isScrollEnabled = false
        mainScrollView.snp.makeConstraints { make in
            make.leading.equalToSuperview()
            make.top.equalToSuperview()
            make.height.equalTo(CS_SCREENHIGHT - cs_navigationBarHeight - 55.0)
            make.width.equalTo(CS_SCREENWIDTH)
            make.bottom.equalToSuperview()
        }
    }
    
    func addPersonViewController() {
        mainScrollView.addSubview(personViewController.view)
        personViewController.view.snp.makeConstraints { make in
            make.leading.top.equalToSuperview()
            make.width.equalToSuperview()
            make.height.equalToSuperview()
        }
    }
    
    func addDynamicViewController() {
        mainScrollView.addSubview(dynamicViewController.view)
        dynamicViewController.view.snp.makeConstraints { make in
            make.top.equalToSuperview()
            make.leading.equalTo(personViewController.view.snp.trailing)
            make.width.equalToSuperview()
            make.height.equalToSuperview()
        }
    }
    
    func addAlbumViewController() {
        mainScrollView.addSubview(albumViewController.view)
        albumViewController.view.snp.makeConstraints { make in
            make.top.equalToSuperview()
            make.leading.equalTo(dynamicViewController.view.snp.trailing)
            make.width.equalToSuperview()
            make.height.equalToSuperview()
        }
    }

每个子页面控制器的内容我们并不需要关心,那么整体页面的架构就已经搭建好了。

2.实现两级列表同时滑动

那么接下来我们只需要两级列表都能响应我们的上下滑动手势就算是成功一大半了。

为了让两个列表共同识别我们的手势,需要使用一个比较关键的方法:

gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:) -> Bool

这个方法允许我们在一个视图中同时识别多个手势,从而实现平滑的用户体验。通过返回 true,我们告诉系统应该允许当前手势识别器与其他手势识别器同时工作,这对于处理复杂的手势交互至关重要。

我们只需要继承自UITableView创建一个PHTableView并实现这个方法返回一个true,代码如下:

class PHTableView: UITableView,UIGestureRecognizerDelegate {

    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        return true
    }
}

将PHProfileViewController中的UITableView替换为PHTableView。同时子视图控制器的基类中的列表也使用PHTableView来实现,代码如下:

    
    /// 列表
    let tableView = PHTableView(frame: .zero, style: .plain)
    
    override func viewDidLoad() {
        super.viewDidLoad()
        addTableView()
    }
    
    func addTableView() {
        view.addSubview(tableView)
        tableView.delegate = self
        tableView.dataSource = self
        tableView.snp.makeConstraints { make in
            make.edges.equalToSuperview()
        }
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "UITableViewCell")
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 0
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        return tableView.dequeueReusableCell(withIdentifier: "UITableViewCell", for: indexPath)
    }

但这时候当你滑动列表的时候会发现一个奇怪的现象,父视图控制器的列表在向上滑,同时子视图控制器的列表相对于父视图控制器的列表也在向上滑。效果如下(仔细看奥):

iOS 中使用 UITableView 实现多个二级列表与共享组标题-LMLPHP

3.通过计算控制滑动列表

接下来我们需要在PHProfileViewController以及子页面控制器(可以直接在基类操作),计算出什么时候谁可以滑动,那么就实现了一个完美的二级多个子列表联动了。

那我们首先来实现,当组标题未置顶前子视图控制器中的列表不允许滑动,当组标题置顶后设置子视图控制器中的列表允许滑动。

先来出来父视图控制器,我们先来创建一个是否可以滑动的属性,来判断它的列表是否可以滑动,以及它的偏移量还有另外两个常量:

  /// 是否可以滑动
    private var canScroll = true
    /// 当前Y轴偏移
    private var offsetY: CGFloat = 0.0
    /// 组标题高度
    private let sectionHeaderHeight: CGFloat = 55.0
    /// 用户信息高度
    private let profileCellHeight: CGFloat = 250.0

通过scrollViewDidScroll代理方法,判断当组标题置顶时(偏移量达到一个值)开始运行子页面控制中的列表滑动,代码如下:

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        offsetY = scrollView.contentOffset.y
        if offsetY >= (sectionHeaderHeight + profileCellHeight) {
            // 通知子页面可以滑动
            canScroll = false
            NotificationCenter.default.post(name: kCSNotificationChildCanScroll, object: true)
        }

    }

而在子视图控制器中我们也需要创建一个canScroll来表明它的列表是否可以滑动,默认为false,并监听kCSNotificationChildCanScroll通知,代码如下:

    /// 列表是否可以滚动
    var canScroll = false

    
    func addNotification() {
        NotificationCenter.default.addObserver(self, selector: #selector(changeScroll), name: kCSNotificationChildCanScroll, object: nil)
    }
    @objc func changeScroll(notification: Notification) {
        if let object = notification.object as? Bool, object == true {
            canScroll = object
        }
    }

实现scrollViewDidScroll方法根据canScroll的值来决定列表是否可以滑动:

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        if canScroll == false  {
            scrollView.contentOffset = CGPoint(x: 0.0, y: 0.0)
        }
    }

这样当列表置顶后父视图控制中的列表就不可以滑动了,而子视图控制器的列表则开始滑动,这个切换过程是很丝滑的。

但是当列表向下滑动时,我们会遇到另外一个问题,当子列表滑到顶部之后,父列表仍然不能滑动,因为我们设置了它不可以滑动。

这就需要我们在合适的时间来通知父视图控制中的列表开始滑动,并设置子视图控制中的列表不可以滑动。

毫无疑问,父视图控制器也需要监听一下消息,代码如下:

    func addNotification() {
        NotificationCenter.default.addObserver(self, selector: #selector(changeScroll), name: kCSNotificationGroupCanScroll, object: nil)
    }
    //MARK: 通知状态改变
    @objc func changeScroll(notification:Notification) {
        if let object = notification.object as? Bool{
            canScroll = object
        }
    }

而子视图控制器需要在合适的时机来发送通知,也就是当它的列表已经滑到最顶端时,代码如下:

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        if canScroll == false  {
            scrollView.contentOffset = CGPoint(x: 0.0, y: 0.0)
        }
        if scrollView.contentOffset.y <= 0 {
            //通知父视图可以滚动
            if canScroll {
                canScroll = false
                NotificationCenter.default.post(name: kCSNotificationGroupCanScroll, object: true)
            }
        }
    }

这样一来整个列表就丝滑多了,不管是上滑还是下滑,将非常流畅,但我们还需要注意在切换组标题时,或者说切换子页面控制器时,需要检测一下当前子视图控制器列表和父视图控制器列表的可滑动状态。

结语

示例代码​​​​​​​

通过本文的讲解,我们了解了如何利用 UIScrollView 和 UITableView 实现多个二级列表共享组标题的效果。通过合理的手势处理和视图布局,我们可以创建一个用户体验良好的界面,让数据展示更加直观和美观。

在实现过程中,gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:) -> Bool 方法起到了关键作用,确保了多个手势的同时识别,使得我们的交互更加顺畅。希望通过这篇文章,你能够掌握这一技巧,并应用到实际的开发中去。

未来,随着 iOS 的不断发展,相信会有更多强大的控件和方法帮助我们实现更加丰富和复杂的用户界面设计。感谢你的阅读,期待你在开发之路上取得更多的突破!

07-29 20:41