[Swift]使用UITableView+UICollectionView實(shí)現(xiàn)二維選擇(四個(gè)方向滑動(dòng))

項(xiàng)目中有這么一個(gè)需求: 左右滑動(dòng)選擇種類, 上下滑動(dòng)選擇大小;即在同一個(gè)滾動(dòng)視圖里, 可以同時(shí)向四個(gè)方向滾動(dòng), 以滿足不同的選擇. 先看一個(gè)效果圖:


像效果圖中這樣: 左右滑動(dòng)選擇顏色, 上下滑動(dòng)選擇尺寸, 單擊選中某項(xiàng);

本篇文章不涉及如何使用本封裝, 只是一些實(shí)現(xiàn)思路, 如果只是想要使用這個(gè)效果, 可直接參看 LQ4DirectionsScrollView - README, 具體demo源碼, 參看: LQ4DirectionsScrollView

初次拿到這個(gè)需求時(shí), 想的是兩個(gè)滾動(dòng)視圖結(jié)合使用, 應(yīng)該能實(shí)現(xiàn). 事實(shí)上也應(yīng)該是這么做, 但是實(shí)現(xiàn)起來(lái)卻沒(méi)那么簡(jiǎn)單;
最終選擇使用UITableView來(lái)實(shí)現(xiàn)上下滑動(dòng), tableViewCell里添加UICollectionView來(lái)實(shí)現(xiàn)左右滑動(dòng).
確定了使用這兩大滾動(dòng)視圖, 接下來(lái)就是來(lái)實(shí)現(xiàn)交互了, 這里有幾個(gè)問(wèn)題需要解決:

    1. 數(shù)據(jù)源如何分配;
    1. 如何滾動(dòng)懸停;
    1. 如何控制滾動(dòng)敏感度;
    1. 如何實(shí)現(xiàn)聯(lián)動(dòng);
    1. 如何解決上下左右滑動(dòng)時(shí)的手勢(shì)沖突;

這幾個(gè)問(wèn)題, 是實(shí)現(xiàn)這個(gè)效果必須要解決的; 下面這兩個(gè)問(wèn)題是我在封裝時(shí), 想要最大限度的減少?gòu)?fù)用時(shí)需要修改的代碼, 需要解決的:

    1. 如何實(shí)現(xiàn)數(shù)據(jù)模型的解耦, 實(shí)現(xiàn)效果時(shí)不用關(guān)心數(shù)據(jù)模型是怎樣的;
    1. 如何實(shí)現(xiàn)cell的解耦;

接下來(lái)就來(lái)一個(gè)一個(gè)解決這些問(wèn)題:

1. 數(shù)據(jù)源分配

分析展示結(jié)果可知,使用UITableView 來(lái)作為外層容器, 其數(shù)據(jù)源是一個(gè)數(shù)組, 針對(duì)tableViewCell, 里面是用UICollectionView來(lái)實(shí)現(xiàn)的, 又是一個(gè)數(shù)組, 所以最終數(shù)據(jù)源為: 大數(shù)組內(nèi)包含小數(shù)組, 小數(shù)組內(nèi)存放數(shù)據(jù)模型, 將小數(shù)組分配給tableViewCell, 由tableViewCell將小數(shù)組內(nèi)容分配給UICollectionView, 然后由collectionViewCell來(lái)展示最終的數(shù)據(jù)內(nèi)容. 下面是我在demo中設(shè)置數(shù)據(jù)源的方法:

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 = ["紅", "橙", "黃", "綠", "青", "藍(lán)", "紫", "隨機(jī)"]
        
        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)
        }
    }

對(duì)于我的封裝, 實(shí)際使用時(shí)是不用關(guān)心數(shù)據(jù)內(nèi)的具體model是怎樣的, 但是數(shù)據(jù)結(jié)構(gòu)一定要是這樣的: 大數(shù)組內(nèi)含小數(shù)組, 小數(shù)組存放數(shù)據(jù)模型.

2. 滾動(dòng)懸停

滾動(dòng)過(guò)程中, 達(dá)到一定幅度, 就要定位到下一個(gè)cell, 這在UITableView或者UICollectionView的代理方法中是無(wú)法實(shí)現(xiàn)的, 但是, 可以使用UIScrollView的代理方法, 經(jīng)過(guò)多次嘗試, 最后發(fā)現(xiàn)只需要實(shí)現(xiàn)下面兩個(gè)代理方法即可:

func scrollViewWillBeginDragging(_ scrollView: UIScrollView)

func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView)

這里會(huì)有一個(gè)問(wèn)題, 就是不是每次拖動(dòng)結(jié)束都會(huì)走上面第二個(gè)代理方法的, 因?yàn)檫@個(gè)是滾動(dòng)即將減速的時(shí)候才會(huì)調(diào)用, 如果在拖動(dòng)過(guò)程中手指一直沒(méi)有離開(kāi)屏幕, 到一定幅度后就停止了, 這樣是不會(huì)有減速過(guò)程的, 也就是不會(huì)走這個(gè)代理方法, 一開(kāi)始我是實(shí)現(xiàn)下面這個(gè)方法來(lái)處理這種操作的:

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

但是這樣總感覺(jué)不是最優(yōu)的解決方法, 后來(lái)發(fā)現(xiàn)只需要打開(kāi)UISCrollView的 isPagingEnabled 屬性即可:

var isPagingEnabled: Bool

只需要將此屬性設(shè)置為true, 不管怎么滑動(dòng), 都會(huì)有一個(gè)減速的過(guò)程, 即總是會(huì)走上面那個(gè)代理方法. 接下來(lái)的就是完善相應(yīng)的邏輯了.

怎么判斷是應(yīng)該滾動(dòng)到下一個(gè)cell, 還是停留在原來(lái)的cell呢?
方式一:
開(kāi)始的時(shí)候, 我是先獲取到當(dāng)前已顯示cell, 如果是兩個(gè)則先去獲取下一個(gè), 然后將此cell的中心點(diǎn)映射到當(dāng)前的視圖上, 再去判斷這個(gè)中心點(diǎn)的位置來(lái)確定是滾動(dòng)到下一個(gè)cell, 還是停留在當(dāng)前的cell.
首先記錄一下初始狀態(tài):

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

這里主要是記錄是否開(kāi)始滑動(dòng)以及開(kāi)始時(shí)的偏移量, 在這種方式中, 他的作用主要是用來(lái)判斷滑動(dòng)的方向的, 然后再在即將減速的代理方法里實(shí)現(xiàn)如下邏輯:

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 {
            //向下滑動(dòng)
            if cells.count > 1 {
                cell = cells[1]
            }
            
//            print("---向下滑動(dòng)-----\(cells.count)-------|\(cellF.y)--------")
            
            if cellF.y < 130 && cells.count > 1{
                cell = cells[0]
            }
        } else {
//           print("----向上滑動(dòng)----\(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)

這樣做, 也能得到相應(yīng)的結(jié)果: 正確滾動(dòng)到下一個(gè)cell, 但是, 判斷是否滾動(dòng)到下一個(gè)cell的變量十分模糊, 不容易靈活改變, 而且, 總感覺(jué)有些復(fù)雜, 思路不是那么清晰. 所以就想直接使用IndexPath來(lái)滾動(dòng)到下一個(gè), 而不去獲取可視區(qū)域的cell, 經(jīng)過(guò)一些嘗試, 最終找到了下面這個(gè)解決方案.
方式二:
初始狀態(tài)的記錄同方式一, 只不過(guò), 下一步的邏輯不再像上面那樣處理, 而是采用如下的方式:

func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) {
        
        if isBeginDrag {
            // 計(jì)算滑動(dòng)幅度
            let currentOffset = scrollView.contentOffset.y - beginOffset
            //向上滑動(dòng)
            if currentOffset - effectiveSlidingDistance > 0 {
                // 如果滑動(dòng)達(dá)到一定幅度, 則滾動(dòng)到下一個(gè)
                currentSection += 1
                if currentSection >= dataSource.count {
                    currentSection = dataSource.count - 1
                }
            } else if currentOffset + effectiveSlidingDistance < 0  {
                // 如果滑動(dòng)達(dá)到一定幅度, 則滾動(dòng)到前一個(gè)
                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)
            }
        }
    }

這里的邏輯是先使用開(kāi)始減速時(shí)的偏移量減去起始狀態(tài)記錄的偏移量, 來(lái)判斷滑動(dòng)的方向, 然后根據(jù)偏移量來(lái)決定是否滾動(dòng)到下一個(gè)cell, 整體思路比方式一清楚, 而且有一個(gè)好處就是, 可以靈活控制滾動(dòng)的有效距離,即下一個(gè)問(wèn)題的滾動(dòng)靈敏度.

3. 控制滾動(dòng)靈敏度

滾動(dòng)的靈敏度就是滑動(dòng)多大距離時(shí), 才去滾動(dòng)到下一個(gè)cell, 這個(gè)值, 使用上面的方式二可以很容易就能做到, 這里我也設(shè)置了一個(gè)屬性:

/// 有效滑動(dòng)距離, 值越小, 滑動(dòng)越靈敏
    var effectiveSlidingDistance: CGFloat = 30.0

默認(rèn)是30, 當(dāng)滑動(dòng)30位移的時(shí)候就會(huì)滾動(dòng)到下一個(gè)cell, 當(dāng)然, 這個(gè)值可以根據(jù)需求修改, 不能為負(fù)數(shù).

4. 實(shí)現(xiàn)聯(lián)動(dòng)

這部分內(nèi)容稍有些難度, 花費(fèi)了一些時(shí)間來(lái)實(shí)現(xiàn)這個(gè)效果;
在一個(gè)tableViewCell中的collectionView向左或者向右滑動(dòng)時(shí), 需要其他的tableViewCell中的collectionView也 "跟著滑動(dòng)" , 即此時(shí)如果上下滑動(dòng), 需要出現(xiàn)的tableViewCell定位到與之一致的數(shù)據(jù)位置.
滾動(dòng)到指定的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的滾動(dòng)到指定cell的方法, 這里有個(gè)變量很關(guān)鍵: currentSection 和 currentIndex, 其實(shí)他們記錄的值是一個(gè)意思.

注意: 這里我是使用一個(gè)區(qū)里返回一個(gè)cell來(lái)展示數(shù)據(jù)源的, 所以修改的是section, 如果是一個(gè)區(qū)顯示所有的cell, 則應(yīng)該修改的是row.

在tableView界面需要記錄的是當(dāng)前滾動(dòng)到的section, 即: currentSection; 其默認(rèn)值為0, 初始時(shí), 需要滾動(dòng)到視圖中間, 重寫 layoutSubviews, 在這里滾動(dòng)到初始狀態(tài):

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

然后在scrollview的代理方法里修改其值, 并滾動(dòng)到相應(yīng)的位置, 即上面第二個(gè)問(wèn)題方式二中的代理方法scrollViewWillBeginDecelerating中的內(nèi)容; 這個(gè)還比較簡(jiǎn)單, 難的是tableViewCell中的collectionView, 要滾動(dòng)到指定的collectionViewCell, 為此設(shè)置了一個(gè)屬性:

var currentIndex: Int = 0

這個(gè)屬性有兩個(gè)作用, 一個(gè)是確定展示當(dāng)前cell的時(shí)候, 其值的collectionView應(yīng)該滾動(dòng)到哪里, 另一個(gè)是記錄當(dāng)前的collectionViewCell滾動(dòng)到了哪里的索引, 并把此值回調(diào)給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])
            }
        }

這里回調(diào)閉包里的index就是記錄當(dāng)前collectionView中cell的索引, 需要從tableViewCell中傳回來(lái), 也得賦值給tableViewCell, 使其中的collectionView滾動(dòng)到指定的cell位置. 同樣是重寫的tableViewCell的layoutSubviews方法, 來(lái)配置新出現(xiàn)的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)
    }

大致就是這么個(gè)思路, 具體的實(shí)現(xiàn)可以直接閱讀源碼.

5. 解決上下左右滑動(dòng)時(shí)的手勢(shì)沖突

在上面這些問(wèn)題解決后, 基本能實(shí)現(xiàn)四個(gè)方向的滑動(dòng), 但是對(duì)手勢(shì)的操作有些苛刻: 如果在左右滑動(dòng)的時(shí)候有上下方向的位移, 或者上下滑動(dòng)的時(shí)候有左右方向的位移, 就會(huì)有下面的情況發(fā)生:

手勢(shì)沖突

這在操作的時(shí)候肯定是不允許的, 但是對(duì)于操作的要求也有點(diǎn)太高了, 沒(méi)辦法只能想法解決. 第一想到的肯定是手勢(shì)沖突, 但是這個(gè)沖突不是那么容易捕捉, 根據(jù)滑動(dòng)方向? 還是手勢(shì)的方向? 如果限定tableView只響應(yīng)上下的手勢(shì), collectionView只響應(yīng)左右的手勢(shì), 先不說(shuō)這么限定本身就有難度, 而且左右滑動(dòng)的時(shí)候也是有上下方向的位移的, 所以即使限定成功, 他們的響應(yīng)還是會(huì)有沖突的.
后來(lái)想到利用滑動(dòng)的偏移量來(lái)做限定, 因?yàn)楫吘乖谀阕笥一瑒?dòng)的時(shí)候, 其上下方向的位移是很小的. 加上之后也沒(méi)有達(dá)到預(yù)期的效果. 無(wú)奈, 就分別打印了collectionView和tableView中計(jì)算得來(lái)的currentOffset值, 發(fā)現(xiàn): 當(dāng)上下滑動(dòng)的時(shí)候(即滑動(dòng)的tableView), 左右的偏移量(collectionView)是不正常的, 比當(dāng)前手勢(shì)的移動(dòng)位移大了很多; 同樣, 在上下滑動(dòng)的時(shí)候, 左右方向也大了很多. 這也是上面說(shuō) "利用滑動(dòng)的偏移量來(lái)做限定"不可行.
在分析了這兩個(gè)輸出值后, 最后發(fā)現(xiàn): 無(wú)論是上下滑動(dòng)(滑動(dòng)tableView), 還是左右滑動(dòng)(滑動(dòng)collectionView), 雖然四個(gè)方向都有手勢(shì)位移, 但是只會(huì)有一個(gè)滾動(dòng)視圖響應(yīng) scrollViewWillBeginDragging 方法, 得到這個(gè)結(jié)論, 又重新看到了希望, 最后加了一個(gè)變量:

private var isBeginDrag: Bool = false

也就是, scrollViewWillBeginDecelerating 方法中變量isBeginDrag的作用. 在各自的scrollViewWillBeginDragging方法中修改這個(gè)變量, 即可分辨出是哪個(gè)滾動(dòng)視圖響應(yīng)的手勢(shì):

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

到此, 總算實(shí)現(xiàn)了文章開(kāi)頭的gif圖片的展示效果.


在項(xiàng)目暫時(shí)告一段落的時(shí)候, 重新回頭看了這個(gè)效果實(shí)現(xiàn)的代碼, 耦合性極高, 基本沒(méi)有復(fù)用的可能性, 就想著把這個(gè)效果單獨(dú)封裝成一個(gè)通用的控件來(lái)使用, 雖然實(shí)現(xiàn)過(guò)一遍, 再次去重構(gòu)這部分的代碼, 還是花費(fèi)了不少時(shí)間的, 最后精簡(jiǎn)到demo中的這四五百行的代碼.
為提高復(fù)用性, 在封裝的時(shí)候就遇到下面的兩個(gè)問(wèn)題:

6. 解耦數(shù)據(jù)模型

不同的需求, 數(shù)據(jù)模型是不一樣的, 不同的人定義的模型, 類名是不一樣的, 為了實(shí)現(xiàn)盡可能的減少修改已封裝的內(nèi)容, 嘗試了一些方法: 使用協(xié)議, 繼承, Extension好像都不行. 最后找到兩種可行的方案:
其一是使用類型聲明: typealias type name = type expression
其二是使用泛型
假設(shè)我在封裝時(shí), 使用的model類名為: LQ4Model
使用時(shí)真正的數(shù)據(jù)模型類名為: LQ4DirectionsModel
第一種方式需要在聲明好數(shù)據(jù)模型時(shí)加上: 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
}

這樣雖然也能實(shí)現(xiàn)數(shù)據(jù)模型的解耦, 但是在最后選擇回調(diào)的方法里返回的數(shù)據(jù)模型是 LQ4Model, 而不是LQ4DirectionsModel, 雖然兩者是等價(jià)的, 但總感覺(jué)是兩個(gè)不同的類. 其實(shí)這種實(shí)現(xiàn)方式, 是完全沒(méi)有問(wèn)題的.

第二種方法: 泛型
在封裝時(shí), 為類添加了一個(gè)泛型類型:

class LQ4DirectionsScrollView<T>: UIView

這個(gè)類型就是將來(lái)具體數(shù)據(jù)模型的占位類型, 封裝時(shí)以此作為數(shù)據(jù)model來(lái)使用, demo中使用的是這種方式實(shí)現(xiàn), 所以源碼中你會(huì)看到一個(gè)字母: T. 這樣做的好處是, 實(shí)際是什么類型, 最后返回的就是什么類型, 弊端是, 在使用時(shí)需要指明這個(gè)泛型類型的實(shí)際類型:

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

上面<>中的內(nèi)容, 就是占位類型 T 的實(shí)際數(shù)據(jù)模型類型.

7. 解耦cell

同樣是為了減少修改源碼中的內(nèi)容, 需要對(duì)使用的collectionViewCell進(jìn)行解耦, 因?yàn)檫@個(gè)cell比較特殊, 是需要根據(jù)業(yè)務(wù)要展示的內(nèi)容來(lái)設(shè)置的, 所以是無(wú)法通用設(shè)計(jì)的. 但是對(duì)于封裝來(lái)說(shuō), 我也是不需要知道你的collectionViewCell具體是怎樣的, 只要定義一些規(guī)則, 必須遵循這些規(guī)則. 其實(shí)實(shí)現(xiàn)方式是上個(gè)問(wèn)題中的第一種方式: typealias type name = type expression:

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

這里有三點(diǎn)需要遵循

  1. LQ4CollectionCell是固定的寫法, 因?yàn)榉庋b中使用就是這個(gè)名稱;
  2. 配置數(shù)據(jù)方法一定要是 func configData(_ model: T),封裝里面是使用這個(gè)方法來(lái)傳遞model的;
  3. class LQ4DirectionsCollectionViewCell<T>: UICollectionViewCell 不要忘記這里的泛型 T.

雖然增加了一些使用的門檻, 但相對(duì)于要修改源文件代碼, 這些門檻是可以接受的, 具體的使用方法可以參看 LQ4DirectionsScrollView -README, 具體demo源碼, 參看: LQ4DirectionsScrollView

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 發(fā)現(xiàn) 關(guān)注 消息 iOS 第三方庫(kù)、插件、知名博客總結(jié) 作者大灰狼的小綿羊哥哥關(guān)注 2017.06.26 09:4...
    肇東周閱讀 15,043評(píng)論 4 61
  • 前言 iOS里的UI控件其實(shí)沒(méi)有幾個(gè),界面基本就是圍繞那么幾個(gè)控件靈活展開(kāi),最難的應(yīng)屬UICollectionVi...
    alenpaulkevin閱讀 32,500評(píng)論 9 176
  • 羅振宇 羅永浩 豆瓣群多參加一些論壇講座 自愛(ài)才會(huì)被愛(ài) 初戀??? 不知如何去愛(ài) 減肥:面食少吃,蛋白質(zhì):牛肉,魚...
    小裁縫sun閱讀 577評(píng)論 0 0
  • 一、讀書清單。 《高倍速閱讀法》未完成 本周又沒(méi)有好好讀書。還是要拿出半小時(shí)時(shí)間看書啊。 二、電影清單 《為什么貓...
    貓餅干閱讀 362評(píng)論 0 0

友情鏈接更多精彩內(nèi)容