用Swift实现一个具有类似UITableView中Cell重用机制的横向滚动控件

这篇文章源于对一个可横向滚动并选择的类目控件的重构。最近在学Swift,所以把这个控件的核心逻辑(Cell重用)用Swift重写出来分享给大家。当然这不是Apple官方的Cell重用机制,因为Apple不开源.这篇文章应该会对正在学习Swift并且想了解Cell重用实现的同学有一定帮助。文章中涉及到的代码都放在了Github(ZXOptionBar-Swift),欢迎大家提issues,求血求虐求羞辱~

第一个版本中这个控件承载了数据、视图、动画等所有的逻辑,使得这个控件在后期变得很难维护。所以我必须重构这个控件,而想到UITableView也是一个可滚动可选择的控件,我为什么不把它做成一个类似UITableView一样的横向滚动的控件呢。通过delegatedataSource把数据和动画从控件中分离出来,通过cell重用减小cell初始化的开支。

可是,Apple的UIKit不开源啊,怎么办呢?twitter为我们提供了很好的参考,那就是TWUI,这是twitter当年在MacOS上实现的类似UIKit的一个库,虽然已经2年没有更新了,但是里面还是有不少可以挖掘的好东西,比如Twitter版的UITabelView的Cell重用机制。

UITableView是分很多section的,但是横向滚动的控件一般只会有一种Cell,不会像UITableView一样需要分很多不同种类的Cell,所以我们可以改造TWUI的Cell重用机制,使它更适合横向滚动的视图控件。

Note:之前在团队内部做过一个这个控件的Cell重用的分享,用Keynote演示,不过是用Objective-C描述的,对于理解上会有帮助。Keynote文件也在Github上。

设计分析

  • 需要通过delegate设计模式将数据和视图分离
  • 需要用继承自UIScrollView的类来处理类似UITableView的逻辑(ZXOptionBar)
  • 需要用继承自UIView的类来处理类似UITableViewCell的逻辑(ZXOptionBarCell)
  • ZXOptionBar中需要一个容器来存放可以重用的Cell
  • ZXOptionBarCell中需要一个容器来存放当前显示的Cell
  • 使用layoutSubviews()方法来处理Cell重用逻辑
  • 需要的工具方法:
    • 计算可见区域Rect visibleRect()
    • 计算可见的Cell的下标index indexsForVisibleColumns()
    • 计算指定区域Rect下的Cell的下标index indexsForColumnInRect()
    • 计算指定下标的Cell的Rect大小 rectForColumnAtIndex()

核心实现

通过delegate设计模式分离数据与视图

使用过UITableView的同学肯定知道,我们在使用UITableView的时候都是通过它的delegate和dataSource的代理方法将数据告诉我们的UITableViewZXOptionBar也可以通过这样的方式将我们的数据和视图分离:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// MARK: - ZXOptionBarDataSource
protocol ZXOptionBarDataSource: NSObjectProtocol {

    func numberOfColumnsInOptionBar(optionBar: ZXOptionBar) -> Int
    func optionBar(optionBar: ZXOptionBar, cellForColumnAtIndex index: Int) -> ZXOptionBarCell
}

// MARK: - ZXOptionBarDelegate
@objc protocol ZXOptionBarDelegate: UIScrollViewDelegate {

    // Display customization
    optional func optionBar(optionBar: ZXOptionBar, willDisplayCell cell: ZXOptionBarCell, forColumnAtIndex index: Int)
    optional func optionBar(optionBar: ZXOptionBar, didEndDisplayingCell cell: ZXOptionBarCell, forColumnAtIndex index: Int)

    // Variable height support
    optional func optionBar(optionBar: ZXOptionBar, widthForColumnsAtIndex index: Int) -> Float

    //Select
    optional func optionBar(optionBar: ZXOptionBar, didSelectColumnAtIndex index: Int)
    optional func optionBar(optionBar: ZXOptionBar, didDeselectColumnAtIndex index: Int)

    //Reload
    optional func optionBarWillReloadData(optionBar: ZXOptionBar)
    optional func optionBarDidReloadData(optionBar: ZXOptionBar)

}

然后在ZXOptionBar中声明两个变量:

1
2
3
// Mark: Var
weak var barDataSource: ZXOptionBarDataSource?
weak var barDelegate: ZXOptionBarDelegate?

并且在初始化方法中对其赋值

1
2
3
4
5
6
7
8
// MARK: Method
convenience init(frame: CGRect, barDelegate: ZXOptionBarDelegate, barDataSource:ZXOptionBarDataSource ) {
  self.init(frame: frame)
  self.delegate = delegate
  self.barDataSource = barDataSource
  self.barDelegate = barDelegate
  .....
}

心细的同学可能已经发现了,delegate和dataSource为什么在初始化方法里赋值啊,我记得 UITableView不是这样子的,一定是我打开的方式不对!限于这已经超出本文的范畴,想深入了解的可以看看Matt大神的API Design,可以看看文中的Rule 3: Required settings should be initializer parameters,不过我建议大家花点时间全文阅读一遍,肯定受益匪浅!

Cell重用逻辑实现

定义容器类

通过上面的办法我们可以把数据从我们的控件中分离出去,接下去就是最激动人心的时候了,我们需要实现我们的Cell重用的逻辑。 首先,我们申明两个Dictionary的容器类,一个用来存放可重用的Cell,一个用来存放当前屏幕显示的Cell

1
2
3
// MARK: Private Var
private var reusableOptionCells: Dictionary<String, NSMutableArray>!
private var visibleItems: Dictionary<String, ZXOptionBarCell>!

当然别忘了在我们的convenience init 方法中对其赋值,这里我就不一一贴代码了,大家可以在我的Github上的ZXOptionBar-Swift库自行下载代码对应这边博客阅读,这里只贴一些核心的关键代码。

重写layoutSubviews()

layoutSubviews()填塞所有Cell重用的逻辑,所以我们可以将一些独立模块的逻辑先抽离出来成一个个独立方法,读者可以先看看源码中以下几个方法先熟悉以下:

1
2
3
4
5
6
7
8
9
10
11
12
private func visibleRect() -> CGRect {
  ...计算可见区域Rect
}
private func indexsForVisibleColumns() -> Array<String> {
  ...计算可见的Cell的下标index
}
private func indexsForColumnInRect(rect: CGRect) -> Array<String> {
  ...计算指定区域Rect下的Cell的下标index
}
private func rectForColumnAtIndex(index: Int) -> CGRect {
  ...计算指定下标的CellRect大小
}

好了,咱先来分析分析我们的逻辑

1
2
3
4
左滑前可见Cellindex:            0 1 2 3 4 5 6 7
左滑后可见Cellindex:                2 3 4 5 6 7 8 9
需要删除的Cellindex:            0 1
需要添加的Cellindex:                            8 9









这样我们就可以发现我们需要计算的信息了:

  • 滑动前老的可见的Cell的index(oldVisibleIndex);
  • 滑动后新的可见的Cell的index(newVisibleIndex);
  • 需要删除的Cell的index(indexsToRemove);
  • 需要添加的Cell的index(indexsToAdd).
1
2
3
4
5
6
7
8
let oldVisibleIndex: Array<String> = self.indexsForVisibleColumns()
let newVisibleIndex: Array<String> = self.indexsForColumnInRect(visible)

var indexsToRemove: NSMutableArray = NSMutableArray(array: oldVisibleIndex)
indexsToRemove.removeObjectsInArray(newVisibleIndex)

var indexsToAdd: NSMutableArray = NSMutableArray(array: newVisibleIndex)
indexsToAdd.removeObjectsInArray(oldVisibleIndex)

然后我们要做的就是将滚动出屏幕的Cell删除掉:

1
2
3
4
5
6
7
//delete the cells which frame out
for i in indexsToRemove {
  let cell: ZXOptionBarCell = self.cellForColumnAtIndex(self.indexFromIdentifyKey(i as String))!
  self.enqueueReusableCell(cell)
  cell.removeFromSuperview()
  self.visibleItems.removeValueForKey(i as String)
}


enqueueReusableCell()方法就是将滚出屏幕的Cell放到我们的重用Cell容器中(reusableOptionCells),具体实现可以看工程源码。

最后就是将进入屏幕的Cell添加进来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//add the new cell which frame in
for i in indexsToAdd {

  let indexToAdd: Int = self.indexFromIdentifyKey(i as String)

  var cell: ZXOptionBarCell = self.barDataSource!.optionBar(self, cellForColumnAtIndex: indexToAdd)
    cell.frame = self.rectForColumnAtIndex(indexToAdd)
    cell.layer.zPosition = 0
    cell.setNeedsDisplay()
    cell.prepareForDisplay()
    cell.index = indexToAdd
    cell.selected = (indexToAdd == self.selectedIndex)

  if self.barDelegate!.respondsToSelector(Selector("optionBar:willDisplayCell:forColumnAtIndex:")) {
      self.barDelegate!.optionBar!(self, willDisplayCell: cell, forColumnAtIndex: indexToAdd)
  }

  self.addSubview(cell)
  self.visibleItems.updateValue(cell, forKey: (i as String))

}

这里我们通过dataSource的 cellForColumnAtIndex和delegate的willDisplayCell来实现cell的构建和delegate的通知。

对外接口使用

这样我们就可以像使用UITableView一样使用我们的ZXOptionBar:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import UIKit

class ViewController: UIViewController,ZXOptionBarDelegate,ZXOptionBarDataSource {

    internal var optionBar: ZXOptionBar?
    override func viewDidLoad() {
        super.viewDidLoad()
        optionBar = ZXOptionBar(frame: CGRectMake(0, 100, UIScreen.mainScreen().bounds.size.width, 100), barDelegate: self, barDataSource: self)
        self.view.addSubview(optionBar!)
    }




    // MARK: - ZXOptionBarDataSource
    func numberOfColumnsInOptionBar(optionBar: ZXOptionBar) -> Int {
        return 20
    }
    func optionBar(optionBar: ZXOptionBar, cellForColumnAtIndex index: Int) -> ZXOptionBarCell {

        var cell: CustomOptionBarCell? = optionBar.dequeueReusableCellWithIdentifier("ZXOptionBarDemo") as? CustomOptionBarCell
        if cell == nil {
            cell = CustomOptionBarCell(style: .ZXOptionBarCellStyleDefault, reuseIdentifier: "ZXOptionBarDemo")
        }
        cell!.textLabel.text = "Bra-\(index)"
        return cell!

    }

    // MARK: - ZXOptionBarDelegate
    func optionBar(optionBar: ZXOptionBar, widthForColumnsAtIndex index: Int) -> Float {
        return 60
    }

    func optionBar(optionBar: ZXOptionBar, willDisplayCell cell: ZXOptionBarCell, forColumnAtIndex index: Int) {
        println(cell)
        println(index)
    }


}

cellForColumnAtIndex其实和UITableViewcellForColumnAtIndex类似,都会调用一个叫dequeueReusableCellWithIdentifier的接口,这个接口会问存放重用Cell的容器reusableOptionCells有没有可以重用的Cell,有就给我,没有就返回nil,我让cellForColumnAtIndex自己新建一个,这样我们整个重用链就构建出来了:

  1. 一开始重用Cell的容器reusableOptionCells是空的,所以所有的Cell都是新建的。
  2. 当有cell滑出屏幕的时候这个cell被放入reusableOptionCells中。
  3. 当有cell要滑入屏幕的时候就像reusableOptionCells要可重用的Cell,有就重用,没有就再新建一个。

写在最后

Note1:本篇文章只是抽离总结分享了一个OptionBar的核心功能(Cell重用和数据视图分离),读者可以根据自己应有的需要继承或者改写该类,添加新功能(比如:下标指示器indicator,indicator的样式,indicator的动画,类目选中动画之类的)。

Note2:文章中只是罗列了核心代码,具体代码已放在Github上:ZXOptionBar-Swift,感兴趣的同学可以下载下来。