[Swift]使用UITableView+UICollectionView实现二维选择(四个方向滑动)

字数 3270阅读 565

项目中有这么一个需求: 左右滑动选择种类, 上下滑动选择大小;即在同一个滚动视图里, 可以同时向四个方向滚动, 以满足不同的选择. 先看一个效果图:


像效果图中这样: 左右滑动选择颜色, 上下滑动选择尺寸, 单击选中某项;

本篇文章不涉及如何使用本封装, 只是一些实现思路, 如果只是想要使用这个效果, 可直接参看 LQ4DirectionsScrollView - README, 具体demo源码, 参看: LQ4DirectionsScrollView

初次拿到这个需求时, 想的是两个滚动视图结合使用, 应该能实现. 事实上也应该是这么做, 但是实现起来却没那么简单;
最终选择使用UITableView来实现上下滑动, tableViewCell里添加UICollectionView来实现左右滑动.
确定了使用这两大滚动视图, 接下来就是来实现交互了, 这里有几个问题需要解决:

    1. 数据源如何分配;
    1. 如何滚动悬停;
    1. 如何控制滚动敏感度;
    1. 如何实现联动;
    1. 如何解决上下左右滑动时的手势冲突;

这几个问题, 是实现这个效果必须要解决的; 下面这两个问题是我在封装时, 想要最大限度的减少复用时需要修改的代码, 需要解决的:

    1. 如何实现数据模型的解耦, 实现效果时不用关心数据模型是怎样的;
    1. 如何实现cell的解耦;

接下来就来一个一个解决这些问题:

1. 数据源分配

分析展示结果可知,使用UITableView 来作为外层容器, 其数据源是一个数组, 针对tableViewCell, 里面是用UICollectionView来实现的, 又是一个数组, 所以最终数据源为: 大数组内包含小数组, 小数组内存放数据模型, 将小数组分配给tableViewCell, 由tableViewCell将小数组内容分配给UICollectionView, 然后由collectionViewCell来展示最终的数据内容. 下面是我在demo中设置数据源的方法:

func loadData() {
        
        let widths = [400, 200, 300, 400, 500, 300]
        let height = [500, 300, 300, 300, 400, 200]
        let colors = [UIColor.red, UIColor.orange, UIColor.yellow, UIColor.green, UIColor.cyan, UIColor.blue, UIColor.purple, self.radomColor()]
        let names = ["红", "橙", "黄", "绿", "青", "蓝", "紫", "随机"]
        
        for i in 0..<widths.count {
            var tmpArr: [LQ4DirectionsModel] = []
            
            for j in 0..<colors.count {
                let model = LQ4DirectionsModel()
                model.width = CGFloat(widths[i])
                model.height = CGFloat(height[i])
                model.color = colors[j]
                model.name = names[j]
                tmpArr.append(model)
            }
            
            dataSource.append(tmpArr)
        }
    }

对于我的封装, 实际使用时是不用关心数据内的具体model是怎样的, 但是数据结构一定要是这样的: 大数组内含小数组, 小数组存放数据模型.

2. 滚动悬停

滚动过程中, 达到一定幅度, 就要定位到下一个cell, 这在UITableView或者UICollectionView的代理方法中是无法实现的, 但是, 可以使用UIScrollView的代理方法, 经过多次尝试, 最后发现只需要实现下面两个代理方法即可:

func scrollViewWillBeginDragging(_ scrollView: UIScrollView)

func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView)

这里会有一个问题, 就是不是每次拖动结束都会走上面第二个代理方法的, 因为这个是滚动即将减速的时候才会调用, 如果在拖动过程中手指一直没有离开屏幕, 到一定幅度后就停止了, 这样是不会有减速过程的, 也就是不会走这个代理方法, 一开始我是实现下面这个方法来处理这种操作的:

 func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>)

但是这样总感觉不是最优的解决方法, 后来发现只需要打开UISCrollView的 isPagingEnabled 属性即可:

var isPagingEnabled: Bool

只需要将此属性设置为true, 不管怎么滑动, 都会有一个减速的过程, 即总是会走上面那个代理方法. 接下来的就是完善相应的逻辑了.

怎么判断是应该滚动到下一个cell, 还是停留在原来的cell呢?
方式一:
开始的时候, 我是先获取到当前已显示cell, 如果是两个则先去获取下一个, 然后将此cell的中心点映射到当前的视图上, 再去判断这个中心点的位置来确定是滚动到下一个cell, 还是停留在当前的cell.
首先记录一下初始状态:

func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        
        if isBeginDrag {
            return
        }
        isBeginDrag = true
        beginOffset = scrollView.contentOffset.y
    }

这里主要是记录是否开始滑动以及开始时的偏移量, 在这种方式中, 他的作用主要是用来判断滑动的方向的, 然后再在即将减速的代理方法里实现如下逻辑:

let collection = scrollView as! UICollectionView
        let cells = collection.visibleCells
        var cell: UICollectionViewCell = cells[0]
        
        let cellF = cell.convert(cell.contentView.center, to: self)
        
        if beginOffset > scrollView.contentOffset.y {
            //向下滑动
            if cells.count > 1 {
                cell = cells[1]
            }
            
//            print("---向下滑动-----\(cells.count)-------|\(cellF.y)--------")
            
            if cellF.y < 130 && cells.count > 1{
                cell = cells[0]
            }
        } else {
//           print("----向上滑动----\(cells.count)-------|\(cellF.y)--------")
            
            if cellF.y < 160 && cells.count > 1{
                cell = cells[1]
            }
        }
        
        let indexPath = collection.indexPath(for: cell)
        let arr = dataSource[(indexPath?.section)!]
        let model = arr[currentIndex]
        
        print("11111--\(model.color)--\(model.name)--\(model.indexPath)")
        collection.scrollToItem(at: indexPath!, at: .centeredVertically, animated: true)

这样做, 也能得到相应的结果: 正确滚动到下一个cell, 但是, 判断是否滚动到下一个cell的变量十分模糊, 不容易灵活改变, 而且, 总感觉有些复杂, 思路不是那么清晰. 所以就想直接使用IndexPath来滚动到下一个, 而不去获取可视区域的cell, 经过一些尝试, 最终找到了下面这个解决方案.
方式二:
初始状态的记录同方式一, 只不过, 下一步的逻辑不再像上面那样处理, 而是采用如下的方式:

func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) {
        
        if isBeginDrag {
            // 计算滑动幅度
            let currentOffset = scrollView.contentOffset.y - beginOffset
            //向上滑动
            if currentOffset - effectiveSlidingDistance > 0 {
                // 如果滑动达到一定幅度, 则滚动到下一个
                currentSection += 1
                if currentSection >= dataSource.count {
                    currentSection = dataSource.count - 1
                }
            } else if currentOffset + effectiveSlidingDistance < 0  {
                // 如果滑动达到一定幅度, 则滚动到前一个
                currentSection -= 1
                if currentSection < 0 {
                    currentSection = 0
                }
            }
        }
        
        let indexPath = IndexPath(row: 0, section: currentSection)
        let arr = dataSource[currentSection]
        let model = arr[currentIndex]
        
        let table = scrollView as! UITableView
        table.scrollToRow(at: indexPath, at: .middle, animated: true)
        
        if isBeginDrag {
            isBeginDrag = false
            if let handle = didScrolledHandle {
                handle(model)
            }
        }
    }

这里的逻辑是先使用开始减速时的偏移量减去起始状态记录的偏移量, 来判断滑动的方向, 然后根据偏移量来决定是否滚动到下一个cell, 整体思路比方式一清楚, 而且有一个好处就是, 可以灵活控制滚动的有效距离,即下一个问题的滚动灵敏度.

3. 控制滚动灵敏度

滚动的灵敏度就是滑动多大距离时, 才去滚动到下一个cell, 这个值, 使用上面的方式二可以很容易就能做到, 这里我也设置了一个属性:

/// 有效滑动距离, 值越小, 滑动越灵敏
    var effectiveSlidingDistance: CGFloat = 30.0

默认是30, 当滑动30位移的时候就会滚动到下一个cell, 当然, 这个值可以根据需求修改, 不能为负数.

4. 实现联动

这部分内容稍有些难度, 花费了一些时间来实现这个效果;
在一个tableViewCell中的collectionView向左或者向右滑动时, 需要其他的tableViewCell中的collectionView也 "跟着滑动" , 即此时如果上下滑动, 需要出现的tableViewCell定位到与之一致的数据位置.
滚动到指定的cell, 主要是使用下面的方法:

fourDirTable.scrollToRow(at: IndexPath.init(row: 0, section: currentSection), at: .middle, animated: false)

multCollection?.scrollToItem(at: IndexPath(item: 0, section: currentIndex), at: .centeredHorizontally, animated: false)

这是UITableView和UICollectionView的滚动到指定cell的方法, 这里有个变量很关键: currentSection 和 currentIndex, 其实他们记录的值是一个意思.

注意: 这里我是使用一个区里返回一个cell来展示数据源的, 所以修改的是section, 如果是一个区显示所有的cell, 则应该修改的是row.

在tableView界面需要记录的是当前滚动到的section, 即: currentSection; 其默认值为0, 初始时, 需要滚动到视图中间, 重写 layoutSubviews, 在这里滚动到初始状态:

override func layoutSubviews() {
        super.layoutSubviews()
        
        fourDirTable.backgroundColor = self.backgroundColor
        fourDirTable.scrollToRow(at: IndexPath.init(row: 0, section: currentSection), at: .middle, animated: false)
    }

然后在scrollview的代理方法里修改其值, 并滚动到相应的位置, 即上面第二个问题方式二中的代理方法scrollViewWillBeginDecelerating中的内容; 这个还比较简单, 难的是tableViewCell中的collectionView, 要滚动到指定的collectionViewCell, 为此设置了一个属性:

var currentIndex: Int = 0

这个属性有两个作用, 一个是确定展示当前cell的时候, 其值的collectionView应该滚动到哪里, 另一个是记录当前的collectionViewCell滚动到了哪里的索引, 并把此值回调给tableView:

cell.currentIndex = currentIndex
cell.didScrolled { [weak self](index) in
            
            self?.currentIndex = index
            if let handle = self?.didScrolledHandle {
                handle(models[index])
            }
        }
        
        cell.didSelected {[weak self] (index) in
            if let handle = self?.didSelectedHandle {
                handle(models[index])
            }
        }

这里回调闭包里的index就是记录当前collectionView中cell的索引, 需要从tableViewCell中传回来, 也得赋值给tableViewCell, 使其中的collectionView滚动到指定的cell位置. 同样是重写的tableViewCell的layoutSubviews方法, 来配置新出现的tableViewCell中collectionViewCell的位置:

override func layoutSubviews() {
        super.layoutSubviews()
        
        multCollection.frame = self.bounds
        
        let indexPath = IndexPath(item: 0, section: currentIndex)
        multCollection?.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false)
    }

大致就是这么个思路, 具体的实现可以直接阅读源码.

5. 解决上下左右滑动时的手势冲突

在上面这些问题解决后, 基本能实现四个方向的滑动, 但是对手势的操作有些苛刻: 如果在左右滑动的时候有上下方向的位移, 或者上下滑动的时候有左右方向的位移, 就会有下面的情况发生:

手势冲突

这在操作的时候肯定是不允许的, 但是对于操作的要求也有点太高了, 没办法只能想法解决. 第一想到的肯定是手势冲突, 但是这个冲突不是那么容易捕捉, 根据滑动方向? 还是手势的方向? 如果限定tableView只响应上下的手势, collectionView只响应左右的手势, 先不说这么限定本身就有难度, 而且左右滑动的时候也是有上下方向的位移的, 所以即使限定成功, 他们的响应还是会有冲突的.
后来想到利用滑动的偏移量来做限定, 因为毕竟在你左右滑动的时候, 其上下方向的位移是很小的. 加上之后也没有达到预期的效果. 无奈, 就分别打印了collectionView和tableView中计算得来的currentOffset值, 发现: 当上下滑动的时候(即滑动的tableView), 左右的偏移量(collectionView)是不正常的, 比当前手势的移动位移大了很多; 同样, 在上下滑动的时候, 左右方向也大了很多. 这也是上面说 "利用滑动的偏移量来做限定"不可行.
在分析了这两个输出值后, 最后发现: 无论是上下滑动(滑动tableView), 还是左右滑动(滑动collectionView), 虽然四个方向都有手势位移, 但是只会有一个滚动视图响应 scrollViewWillBeginDragging 方法, 得到这个结论, 又重新看到了希望, 最后加了一个变量:

private var isBeginDrag: Bool = false

也就是, scrollViewWillBeginDecelerating 方法中变量isBeginDrag的作用. 在各自的scrollViewWillBeginDragging方法中修改这个变量, 即可分辨出是哪个滚动视图响应的手势:

func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        
        if isBeginDrag {
            return
        }
        isBeginDrag = true
        //...
    }

到此, 总算实现了文章开头的gif图片的展示效果.


在项目暂时告一段落的时候, 重新回头看了这个效果实现的代码, 耦合性极高, 基本没有复用的可能性, 就想着把这个效果单独封装成一个通用的控件来使用, 虽然实现过一遍, 再次去重构这部分的代码, 还是花费了不少时间的, 最后精简到demo中的这四五百行的代码.
为提高复用性, 在封装的时候就遇到下面的两个问题:

6. 解耦数据模型

不同的需求, 数据模型是不一样的, 不同的人定义的模型, 类名是不一样的, 为了实现尽可能的减少修改已封装的内容, 尝试了一些方法: 使用协议, 继承, Extension好像都不行. 最后找到两种可行的方案:
其一是使用类型声明: typealias type name = type expression
其二是使用泛型
假设我在封装时, 使用的model类名为: LQ4Model
使用时真正的数据模型类名为: LQ4DirectionsModel
第一种方式需要在声明好数据模型时加上: typealias LQ4Model = LQ4DirectionsModel, 即完整的LQ4DirectionsModel类声明为:

import UIKit

typealias LQ4Model = LQ4DirectionsModel
class LQ4DirectionsModel: NSObject {

    var width: CGFloat = 0.0
    var height: CGFloat = 0.0
    var name: String = ""
    
    var size: String {
        
        return "\(width)x\(height)cm"
    }
    
    var color: UIColor = UIColor.white
}

这样虽然也能实现数据模型的解耦, 但是在最后选择回调的方法里返回的数据模型是 LQ4Model, 而不是LQ4DirectionsModel, 虽然两者是等价的, 但总感觉是两个不同的类. 其实这种实现方式, 是完全没有问题的.

第二种方法: 泛型
在封装时, 为类添加了一个泛型类型:

class LQ4DirectionsScrollView<T>: UIView

这个类型就是将来具体数据模型的占位类型, 封装时以此作为数据model来使用, demo中使用的是这种方式实现, 所以源码中你会看到一个字母: T. 这样做的好处是, 实际是什么类型, 最后返回的就是什么类型, 弊端是, 在使用时需要指明这个泛型类型的实际类型:

let scroll = LQ4DirectionsScrollView<LQ4DirectionsModel>(frame: frame)

上面<>中的内容, 就是占位类型 T 的实际数据模型类型.

7. 解耦cell

同样是为了减少修改源码中的内容, 需要对使用的collectionViewCell进行解耦, 因为这个cell比较特殊, 是需要根据业务要展示的内容来设置的, 所以是无法通用设计的. 但是对于封装来说, 我也是不需要知道你的collectionViewCell具体是怎样的, 只要定义一些规则, 必须遵循这些规则. 其实实现方式是上个问题中的第一种方式: typealias type name = type expression:

typealias LQ4CollectionCell = LQ4DirectionsCollectionViewCell
class LQ4DirectionsCollectionViewCell<T>: UICollectionViewCell{
// ...
  func configData(_ model: T){
// ...
}
}

这里有三点需要遵循

  1. LQ4CollectionCell是固定的写法, 因为封装中使用就是这个名称;
  2. 配置数据方法一定要是 func configData(_ model: T),封装里面是使用这个方法来传递model的;
  3. class LQ4DirectionsCollectionViewCell<T>: UICollectionViewCell 不要忘记这里的泛型 T.

虽然增加了一些使用的门槛, 但相对于要修改源文件代码, 这些门槛是可以接受的, 具体的使用方法可以参看 LQ4DirectionsScrollView -README, 具体demo源码, 参看: LQ4DirectionsScrollView

推荐阅读更多精彩内容