抛弃UITableView,让所有列表页不再难构建

17,308 阅读13分钟

首先要对点进来的看官说声sorry,我标题党了。😏

虽然抛弃UITableView是不存在的,但是看完这篇文章确实能让90%的列表页抛弃UITableView,让界面易实现易复用。

下面我将以第三人称的叙述方式,通过一个例子比较传统实现和最新实现的手段说明如何让列表页不再难构建。

开始

小明是A公司的iOS程序员,刚入职不久,A公司的产品经理想出来一个新需求,正好安排给小明完成。 产品经理提出要做一个feed流页面,显示用户所关注的其他所有用户的动态。

传统实现

第一个需求:显示用户名和文字内容

产品经理说了用户只能发文字内容,所以列表页也只需要显示用户名和文字内容,就像图片所示,

小明一看这设计图,so easy,UITableView嘛,这cell太简单了,轻车熟路,很快小明就写了大概像这样的代码

class FeedCell: UITableViewCell {
    var imageView: UIImageView
    var nameLabel: UILabel
    var textLabel: UILabel
    
    func init(frame: CGRect) {
        ///布局代码
    }
    
    func setViewModel(_ viewModel: FeedCellModel) {
        imageView.image = viewModel.image
        nameLabel.text = viewModel.name
        textLabel.text = viewModel.content
    }
}

没毛病,小明花了5分钟写完了布局和实现tableview的数据源和代理协议。 产品经理还要求内容默认显示一行,超过省略号表示,点击上去再全部显示,小明想这也容易,在FeedCellModel中加一个表示是否展开的bool量isExpand,然后didSelect代理方法中改变这个值并且reload这一行,在heightForRow代理方法中判断isExpand,返回小明已在FeedCellModel中已经计算的两个高度(初始高度和全部高度)。代码就不展示了哦。 很好,很快,第一版上线了。

第二个需求:点赞

在第二版的计划中,产品经理设计了点赞的功能,如图

于是小明又在FeedCell里加上了这几行代码

var favorBtn: UIButton
var favorLable: UILabel

func init(frame: CGRect) {
    ///再加几行布局favorBtn和favorLable的代码
    }

func favorClick(_ sender: Any) {
    ///在这里请求点赞,然后重新给favorLable赋值
}

然后又到FeedCellModel里面在原有计算高度的地方加一下点赞控件的高度。 很好,目前为止,两个需求都非常快速完美的完成了。

第三个需求:图片展示

只有文字可太单调了,俗话说没图说个jb😂,产品经理又设计了图片展示,需求如图

根据设计图,图片是以九宫格展示,并且要放到内容和点赞中间,这时小明感到有点棘手了,觉得要改的代码不少,用UIButton一个个加的话,无论是计算frame还是约束,都很烦,压根就不想写,或者用CollectionView貌似好一点,设置好与上下视图的约束,根据有没有图片设置隐藏,在FeedCellModel里面根据图片数量重新计算一下高度,这样好像也能完成,改动的地方还能接受(可是笔者已经无法接受了,所以此处没有示例代码),于是乎,又愉快的完成的第三版。

class FeedCell: UITableViewCell {
    var imageCollectionView: UICollectionView
}

第四个需求:评论展示

产品经理又设计了一个新需求,要显示所有的评论并且允许发送人删掉某些不合适的评论。看样子是要往社交方面发展了。 小明想了一下,有这几个思路,可以在FeedCell里再嵌套个tableview,预先计算出高度,在commentCell的删除按钮点击事件里重新计算高度然后删除cell;或者封装一下commentView,还是预先计算出高度,根据数据加对应数量的commentView,删除一个再重新计算一下高度。无论哪一种,都有不小的工作量。

class CommentTableView: UIView {
    var tableView: UITableView
    var comments: [Comment] {
        didSet {
            tableView.reloadData()
        }
    }
    func onDeleteClick(_ sender: UIBUtton) {
       //代理出去处理删除评论事件
    }
}
class FeedCell: UITableViewCell {
    var commentTable: CommentTableView
    func setViewModel(_ viewModel: FeedCellModel) {
        //调整commentTable的高度约束,把数据传入commentTable渲染评论列表
    }
}

这个需求小明花了两天赶在周末前完成了。不过此时他也下定决心,要在周末花点时间找到一种重构方案,毕竟产品经理的想法很多,后期完全可能再加入视频播放、语音播放,甚至在这个feed流中加入比如广告等其他类型的数据,这个FeedCell和tableview将会越来越难以维护,计算高度也将变难,而且牵一发而动全身。

周末空闲时,小明去github上逛了逛,发现了能够拯救他的救世主--IGListKit。

IGListKit

IGListKit是Instagram出的一个基于UICollectionView的数据驱动UI框架,目前在github上有9k+ star,被充分利用在Instagram App上,可以翻墙的同学可以去体验一下,看看Instagram的体验,想想如果那些页面让小明用传统方式实现,那将是什么样的情况。可以这样说,有了IGListKit,任何类似列表的页面UI构建,都将so easy!

首先,得介绍IGList中的几个基本概念。

ListAdapter

适配器,它将collectionview的dataSource和delegate统一了起来,负责collectionView数据的提供、UI的更新以及各种代理事件的回调。

ListSectionController

一个 section controller是一个抽象UICollectionView的section的controller对象,指定一个数据对象,它负责配置和管理 CollectionView 中的一个 section 中的 cell。这个概念类似于一个用于配置一个 view 的 view-model:数据对象就是 view-model,而 cell 则是 view,section controller 则是二者之间的粘合剂。

具体关系如下图所示

周末两天,小明认真学习了一下IGListKit,得益于IGListKit的易用性,当然还有小明的聪明才智,他决定下周就重构feed页。

周一一上班,小明就开始动手用IGListKit重写上面的需求。

准备工作:布局collectionView和绑定适配器

BaseListViewController.swift

let collectionView: UICollectionView = {
        let flow = UICollectionViewFlowLayout()
        let collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: flow)
        collectionView.backgroundColor = UIColor.groupTableViewBackground
        return collectionView
    }()
override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        collectionView.frame = view.bounds
    }

创建adapter,将collectionView和它适配起来

//存放数据的数组,数据模型需要实现ListDiffable协议,主要实现判等,具体是什么后面再说
var objects: [ListDiffable] = [ListDiffable]()
lazy var adapter: ListAdapter = {
        let adapter = ListAdapter(updater: ListAdapterUpdater(), viewController: self)
    return adapter
    }()
override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(collectionView)
        adapter.collectionView = collectionView
        adapter.dataSource = self
    }

实现ListAdapterDataSource协议来提供数据

///返回要在collectionView中显示的所有数据
func objects(for listAdapter: ListAdapter) -> [ListDiffable] {
       return objects
    }
///返回每个数据对应的sectionController,
    func listAdapter(_ listAdapter: ListAdapter, sectionControllerFor object: Any) -> ListSectionController {
    //ListSectionController是抽象基类,不能直接使用,必须子类化,这里这么写是因为是在基类BaseListViewController里。
        return ListSectionController()
    }
///数据为空时显示的占位视图
    func emptyView(for listAdapter: ListAdapter) -> UIView? {
        return nil
    }

因为为了清晰的比较每个需求的变更,所以在demo里每个需求都有一个ViewController,搞了个基类来创建collectionView和adapter。

第一个需求:显示用户名和文字内容

准备两个cell

class UserInfoCell: UICollectionViewCell {

    @IBOutlet weak var avatarView: UIImageView!
    @IBOutlet weak var nameLabel: UILabel!
    public var onClickArrow: ((UserInfoCell) -> Void)?
    override func awakeFromNib() {
        super.awakeFromNib()
        self.avatarView.layer.cornerRadius = 12
    }
    
    @IBAction private func onClickArrow(_ sender: Any) {
        onClickArrow?(self)
    }
    func bindViewModel(_ viewModel: Any) {
        guard let viewModel = viewModel as? UserInfoCellModel else { return }
        self.avatarView.backgroundColor = UIColor.purple
        self.nameLabel.text = viewModel.userName
    }
    
}

class ContentCell: UICollectionViewCell {
    @IBOutlet weak var label: UILabel!
    
    override func awakeFromNib() {
        super.awakeFromNib()
        // Initialization code
    }
   static func lineHeight() -> CGFloat {
        return UIFont.systemFont(ofSize: 16).lineHeight
    }
   static func height(for text: NSString,limitwidth: CGFloat) -> CGFloat {
        let font = UIFont.systemFont(ofSize: 16)
        let size: CGSize = CGSize(width: limitwidth - 20, height: CGFloat.greatestFiniteMagnitude)
        let rect = text.boundingRect(with: size, options: [.usesFontLeading,.usesLineFragmentOrigin], attributes: [NSAttributedString.Key.font:font], context: nil)
        return ceil(rect.height)
    }
    func bindViewModel(_ viewModel: Any) {
        guard let vm = viewModel as? String else { return }
        self.label.text = vm
    }
}

准备sectionController,一个cell对应一个sectionController。这只是一种实现方式,下面还有一种方式(只需要一个sectionController)。

final class UserInfoSectionController: ListSectionController {

    var object: Feed!
    lazy var viewModel: UserInfoCellModel = {
        let model = UserInfoCellModel(avatar: URL(string: object.avatar), userName: object.userName)
        return model
    }()
    
    override func numberOfItems() -> Int {
        return 1
    }

    override func sizeForItem(at index: Int) -> CGSize {
        let width: CGFloat! = collectionContext?.containerSize(for: self).width
        return CGSize(width: width, height: 30)
    }

    override func cellForItem(at index: Int) -> UICollectionViewCell {
        guard let cell = collectionContext?.dequeueReusableCell(withNibName: UserInfoCell.cellIdentifier, bundle: nil, for: self, at: index) as? UserInfoCell else { fatalError() }
        cell.bindViewModel(viewModel as Any)
        cell.onClickArrow = {[weak self] cell in
            guard let self = self else { return }
            let actionSheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
            actionSheet.addAction(UIAlertAction(title: "share", style: .default, handler: nil))
            actionSheet.addAction(UIAlertAction(title: "cancel", style: .cancel, handler: nil))
            actionSheet.addAction(UIAlertAction(title: "delete", style: .default, handler: { (action) in
                NotificationCenter.default.post(name: Notification.Name.custom.delete, object: self.object)
            }))
            self.viewController?.present(actionSheet, animated: true, completion: nil)
        }
        return cell
    }

    override func didUpdate(to object: Any) {
        self.object = object as? Feed
    }
}
class ContentSectionController: ListSectionController {
    var object: Feed!
    var expanded: Bool = false

    override func numberOfItems() -> Int {
        if object.content?.isEmpty ?? true {
            return 0
        }
        return 1
    }

    override func sizeForItem(at index: Int) -> CGSize {
        guard let content = object.content else { return CGSize.zero }
        let width: CGFloat! = collectionContext?.containerSize(for: self).width
        let height = expanded ? ContentCell.height(for: content as NSString, limitwidth: width) : ContentCell.lineHeight()
        return CGSize(width: width, height: height + 5)
    }

    override func cellForItem(at index: Int) -> UICollectionViewCell {
        guard let cell = collectionContext?.dequeueReusableCell(withNibName: ContentCell.cellIdentifier, bundle: nil, for: self, at: index) as? ContentCell else { fatalError() }
        cell.bindViewModel(object.content as Any)
        return cell
    }

    override func didUpdate(to object: Any) {
        self.object = object as? Feed
    }

    override func didSelectItem(at index: Int) {
        expanded.toggle()
        UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.4, initialSpringVelocity: 0.6, options: [], animations: {
            self.collectionContext?.invalidateLayout(for: self, completion: nil)
        }, completion: nil)
    }
}

在ViewController里获取数据,实现数据源协议

class FirstListViewController: BaseListViewController {
override func viewDidLoad() {
        super.viewDidLoad()
        do {
            let data = try JsonTool.decode([Feed].self, jsonfileName: "data1")
            self.objects.append(contentsOf: data)
            adapter.performUpdates(animated: true, completion: nil)
        } catch {
            print("decode failure")
        }
    }

    override func listAdapter(_ listAdapter: ListAdapter, sectionControllerFor object: Any) -> ListSectionController {
        let stack = ListStackedSectionController(sectionControllers: [UserInfoSectionController(),ContentSectionController()])
        stack.inset = UIEdgeInsets(top: 5, left: 0, bottom: 0, right: 0)
        return stack
    }
}

这里用到了框架里的一个类ListStackedSectionController,它是来管理子sectionController的。这里我把每个数据对应看做大组,每个cell显示的数据看做小组,ListStackedSectionController即是大组,它会按照sectionControllers数组顺序从上至下排列子sectionController,有点类似于UIStackView。

第一个需求已经实现了,貌似比原来的实现代码更多了啊,哪变简单了,别着急,继续往下看。

第二个需求:点赞

按照原来的思路,我们得修改原来FeedCell,在里面再加上新的控件,然后再在viewModel里重新计算高度,这其实违反了面向对象的设计原则开闭原则。那么现在该如何去做,我们直接新增一个FavorCell,和对应的一个FavorSectionController,根本不需要碰原有运行良好的代码。

class FavorCell: UICollectionViewCell {
    @IBOutlet weak var favorBtn: UIButton!
    @IBOutlet weak var nameLabel: UILabel!
    var favorOperation: ((FavorCell) -> Void)?
    var viewModel: FavorCellModel?

    override func awakeFromNib() {
        super.awakeFromNib()
        // Initialization code
    }

    @IBAction func onClickFavor(_ sender: Any) {
        self.favorOperation!(self)
    }
    func bindViewModel(_ viewModel: Any) {
        guard let viewModel = viewModel as? FavorCellModel else { return }
        self.viewModel = viewModel
        self.favorBtn.isSelected = viewModel.isFavor
        self.nameLabel.text = viewModel.favorNum
    }
}
class FavorSectionController: ListSectionController {

    var object: Feed!
    lazy var viewModel: FavorCellModel = {
        let vm = FavorCellModel()
        vm.feed = object
        return vm
    }()

    override func numberOfItems() -> Int {
        return 1
    }

    override func sizeForItem(at index: Int) -> CGSize {
        let width: CGFloat! = collectionContext?.containerSize(for: self).width
        return CGSize(width: width, height: 65)
    }
    override func cellForItem(at index: Int) -> UICollectionViewCell {
        guard let cell = collectionContext?.dequeueReusableCell(withNibName: FavorCell.cellIdentifier, bundle: nil, for: self, at: index) as? FavorCell else { fatalError() }
        cell.bindViewModel(viewModel as Any)
        cell.favorOperation = {[weak self] cell in
            guard let self = self else { return }
            self.object.isFavor.toggle()
            let origin: UInt! = self.object.favor
            self.object.favor = self.object.isFavor ? (origin + 1) : (origin - 1)
            self.viewModel.feed = self.object
            self.collectionContext?.performBatch(animated: true, updates: { (batch) in
                batch.reload(in: self, at: IndexSet(integer: 0))
            }, completion: nil)
        }
        return cell
    }

    override func didUpdate(to object: Any) {
        self.object = object as? Feed
    }
}

在ViewController里重新实现一下数据源方法就行了

override func listAdapter(_ listAdapter: ListAdapter, sectionControllerFor object: Any) -> ListSectionController {
        let stack = ListStackedSectionController(sectionControllers: [UserInfoSectionController(),ContentSectionController(),FavorSectionController()])
        stack.inset = UIEdgeInsets(top: 5, left: 0, bottom: 0, right: 0)
        return stack
    }

看,只需要在ListStackedSectionController里新增一个FavorSectionController,就能完成这个需求了。

第三个:图片展示

九宫格的图片展示,用UICollectionView是最简单的实现方式。

class ImageCollectionCell: UICollectionViewCell {
    let padding: CGFloat = 10
    @IBOutlet weak var collectionView: UICollectionView!
    var viewModel: ImagesCollectionCellModel!

    override func awakeFromNib() {
        super.awakeFromNib()
        collectionView.register(UINib(nibName: ImageCell.cellIdentifier, bundle: nil), forCellWithReuseIdentifier: ImageCell.cellIdentifier)
    }
    func bindViewModel(_ viewModel: Any) {
        guard let viewModel = viewModel as? ImagesCollectionCellModel else { return }
        self.viewModel = viewModel
        collectionView.reloadData()
    }
}

extension ImageCollectionCell: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return (self.viewModel?.images.count)!
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ImageCell.cellIdentifier, for: indexPath) as? ImageCell else { fatalError() }
        cell.image = self.viewModel?.images[indexPath.item]
        return cell
    }
}

extension ImageCollectionCell: UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        let width: CGFloat = (collectionView.bounds.width - padding * 2) / 3
        return CGSize(width: width, height: width)
    }
}

class ImageSectionController: ListSectionController {

    let padding: CGFloat = 10

    var object: Feed!
    lazy var viewModel: ImagesCollectionCellModel = {
        let vm = ImagesCollectionCellModel()
        vm.imageNames = object.images
        return vm
    }()

    override func numberOfItems() -> Int {
        if object.images.count == 0 {
            return 0
        }
        return 1
    }

    override func sizeForItem(at index: Int) -> CGSize {
        let width: CGFloat! = collectionContext?.containerSize(for: self).width
        let itemWidth: CGFloat = (width - padding * 2) / 3
        let row: Int = (object.images.count - 1) / 3 + 1
        let h: CGFloat = CGFloat(row) * itemWidth + CGFloat(row - 1) * padding
        return CGSize(width: width, height: h)
    }

    override func cellForItem(at index: Int) -> UICollectionViewCell {
        guard let cell = collectionContext?.dequeueReusableCell(withNibName: ImageCollectionCell.cellIdentifier, bundle: nil, for: self, at: index) as? ImageCollectionCell else { fatalError() }
        cell.bindViewModel(viewModel)
        return cell
    }

    override func didUpdate(to object: Any) {
        self.object = object as? Feed
    }
}

同之前同样的操作,在ListStackedSectionController里把ImageSectionController加进去就👌了。 哦,慢着,这个图片区域好像是在内容的下面和点赞的上面,那就把ImageSectionController放到ContentSectionController和FavorSectionController之间,就行了。

override func listAdapter(_ listAdapter: ListAdapter, sectionControllerFor object: Any) -> ListSectionController {
        let stack = ListStackedSectionController(sectionControllers:
            [UserInfoSectionController(),
             ContentSectionController(),
             ImageSectionController(),
             FavorSectionController()])
        stack.inset = UIEdgeInsets(top: 5, left: 0, bottom: 0, right: 0)
        return stack
    }

这里已经体现出IGListKit相对于传统实现的绝对优势了,高灵活性和高可扩展性。

假如产品经理要把图片放到内容上面或者点赞下面,只需要挪动ImageSectionController的位置就行了,她想怎么改就怎么改,甚至改回原来的需求,现在都将能从容应对😏,按照原来的方式,小明肯定想打死产品经理😂。

第四个需求:评论

评论区域看成单独一组,这一组里cell的数量不确定,得根据Feed中的评论数量生成cellModel,然后进行配置。

class CommentSectionController: ListSectionController {

    var object: Feed!
    lazy var viewModels: [CommentCellModel] = {
        let vms: [CommentCellModel]  = object.comments?.map({ (comment) -> CommentCellModel in
            let vm = CommentCellModel()
            vm.comment = comment
            return vm
        }) ?? []
        return vms
    }()

    override func numberOfItems() -> Int {
        return viewModels.count
    }

    override func sizeForItem(at index: Int) -> CGSize {
        let width: CGFloat! = collectionContext?.containerSize(for: self).width
        return CGSize(width: width, height: 44)
    }

    override func cellForItem(at index: Int) -> UICollectionViewCell {
        guard let cell = collectionContext?.dequeueReusableCell(withNibName: CommentCell.cellIdentifier, bundle: nil, for: self, at: index) as? CommentCell else { fatalError() }
        cell.bindViewModel(viewModels[index])
        cell.onClickDelete = {[weak self] (deleteCell) in
            guard let self = self else {
                return
            }
            self.collectionContext?.performBatch(animated: true, updates: { (batch) in
                let deleteIndex: Int! = self.collectionContext?.index(for: deleteCell, sectionController: self)
                self.viewModels.remove(at: deleteIndex)
                batch.delete(in: self, at: IndexSet(integer: deleteIndex))
            }, completion: nil)
        }
        return cell
    }

    override func didUpdate(to object: Any) {
        self.object = object as? Feed
    }
}

这里把点击commentCell的删除按钮事件代理出来给CommentSectionController处理,在闭包里先对cellModels数组删除,然后调用IGListKit的批量更新操作,在里面删除指定位置的cell。 最后同样的操作,在ListStackedSectionController里面再加一个就又ok了。

小明花了一天就重构完了这个页面,并且再也不怕后面产品经理提出的奇葩需求了。小明决定今天准时下班并且要去吃顿好的。

ListDiffable

ListDiffable协议,这属于IGListKit核心Diff算法的一部分,实现了ListDiffable协议才能使用diff算法,这个算法是计算新老两个数组前后数据变化增删改移关系的一个算法,时间复杂度是O(n),算是IGListKit的特色特点之一。使用的是Paul Heckel 的A technique for isolating differences between files 的算法。

总结

到目前为止,我们用子sectionController+ListStackedSectionController的方式完美实现了四个需求。这是我比较推荐的实现方式,但并不是唯一的,还有两种实现方式ListBindingSectionController(推荐实现)和只需要一个ListSectionController就能实现,已经在demo里实现,这里就不贴出来了,诸位可以去demo里理解。

IGListKit还能非常方便的实现多级列表、带多选功能的多级列表。

当然一样事物不可能只有优点,IGListKit同样拥有缺点,就目前为止我使用的经历来看,主要这几个可能有点坑。

  • 对autolayout支持不好。基本上都是要自己计算cell的size的,不过IGListKit将大cell分成小cell了,计算高度已经变的容易很多了,这个缺点可以忽略了

  • 因为是基于UICollectionView的,所以没有UITableView自带的滑动特性,这一点其实issue里有人提过,但其实这并不属于IGListKit应该考虑的范畴(官方人员这么回复的),目前我想到有两种解决方案,一是自己实现或用第三方库实现UICollectionViewCell的滑动,二是把UITableView嵌套进UICollectionViewCell,这个可能得好好封装一下了。

相信看到这里,诸位看官已经能明显感觉到IGListKit强大的能力,它充分展现了OOP的高内聚低耦合的思想,拥有高易用性、可扩展性、可维护性,体现了化整为零、化繁为简的哲学。

demo:github.com/Bruce-pac/I…, github.com/Bruce-pac/I…