抛弃UITableView,让全部列表页再也不难构建

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

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

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

开始

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

传统实现

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

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

小明一看这设计图,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中已经计算的两个高度(初始高度和所有高度)。代码就不展现了哦。 很好,很快,初版上线了。swift

第二个需求:点赞

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

因而小明又在FeedCell里加上了这几行代码

var favorBtn: UIButton
var favorLable: UILabel

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

func favorClick(_ sender: Any) {
    ///在这里请求点赞,而后从新给favorLable赋值
}
复制代码

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

第三个需求:图片展现

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

根据设计图,图片是以九宫格展现,而且要放到内容和点赞中间,这时小明感到有点棘手了,以为要改的代码很多,用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…