拋棄UITableView,讓所有列表頁不再難構建

小顧Bruce發表於2018-11-25

首先要對點進來的看官說聲sorry,我標題黨了。?

雖然拋棄UITableView是不存在的,但是看完這篇文章確實能讓90%的列表頁拋棄UITableView,讓介面易實現易複用。

下面我將以第三人稱的敘述方式,通過一個例子比較傳統實現和最新實現的手段說明如何讓列表頁不再難構建。

開始

小明是A公司的iOS程式設計師,剛入職不久,A公司的產品經理想出來一個新需求,正好安排給小明完成。 產品經理提出要做一個feed流頁面,顯示使用者所關注的其他所有使用者的動態。

傳統實現

第一個需求:顯示使用者名稱和文字內容

產品經理說了使用者只能發文字內容,所以列表頁也只需要顯示使用者名稱和文字內容,就像圖片所示,

拋棄UITableView,讓所有列表頁不再難構建
小明一看這設計圖,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中已經計算的兩個高度(初始高度和全部高度)。程式碼就不展示了哦。 很好,很快,第一版上線了。

第二個需求:點贊

在第二版的計劃中,產品經理設計了點讚的功能,如圖

拋棄UITableView,讓所有列表頁不再難構建
於是小明又在FeedCell里加上了這幾行程式碼

var favorBtn: UIButton
var favorLable: UILabel

func init(frame: CGRect) {
    ///再加幾行佈局favorBtn和favorLable的程式碼
    }

func favorClick(_ sender: Any) {
    ///在這裡請求點贊,然後重新給favorLable賦值
}
複製程式碼

然後又到FeedCellModel裡面在原有計算高度的地方加一下點贊控制元件的高度。 很好,目前為止,兩個需求都非常快速完美的完成了。

第三個需求:圖片展示

只有文字可太單調了,俗話說沒圖說個jb?,產品經理又設計了圖片展示,需求如圖

拋棄UITableView,讓所有列表頁不再難構建

根據設計圖,圖片是以九宮格展示,並且要放到內容和點贊中間,這時小明感到有點棘手了,覺得要改的程式碼不少,用UIButton一個個加的話,無論是計算frame還是約束,都很煩,壓根就不想寫,或者用CollectionView貌似好一點,設定好與上下檢視的約束,根據有沒有圖片設定隱藏,在FeedCellModel裡面根據圖片數量重新計算一下高度,這樣好像也能完成,改動的地方還能接受(可是筆者已經無法接受了,所以此處沒有示例程式碼),於是乎,又愉快的完成的第三版。

class FeedCell: UITableViewCell {
    var imageCollectionView: UICollectionView
}
複製程式碼

第四個需求:評論展示

拋棄UITableView,讓所有列表頁不再難構建

產品經理又設計了一個新需求,要顯示所有的評論並且允許傳送人刪掉某些不合適的評論。看樣子是要往社交方面發展了。 小明想了一下,有這幾個思路,可以在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 則是二者之間的粘合劑。

具體關係如下圖所示

拋棄UITableView,讓所有列表頁不再難構建

週末兩天,小明認真學習了一下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…

相關文章