First of all, I’d like to say sorry to the viewers who click in. šŸ˜

Ditching a UITableView doesn’t exist, but by the end of this article 90% of all list pages will be ditching a UITableView, making the interface easy to reuse.

In the third person, I’ll show you how to make a list page less difficult to build by comparing traditional and recent implementations with an example.

start

Xiao Ming is an iOS programmer of A company. Shortly after he joined the company, the product manager of A company came up with A new demand and arranged it for Xiao Ming to complete. The product manager came up with the idea of creating a feed stream page that would show all the other users that the user was interested in.

Traditional implementation

First requirement: display username and text content

The product manager said that users can only send text content, so the list page only needs to show the user name and text content, as shown in the picture.

class FeedCell: UITableViewCell {
    var imageView: UIImageView
    var nameLabel: UILabel
    var textLabel: UILabel
    
    func init(frame: CGRect) {
        /// layout code
    }
    
    func setViewModel(_ viewModel: FeedCellModel) {
        imageView.image = viewModel.image
        nameLabel.text = viewModel.name
        textLabel.text = viewModel.content
    }
}
Copy the code

It took Ming 5 minutes to write the layout and implement the datasource and proxy protocol for the TableView. Add a bool isExpand to the FeedCellModel indicating whether or not to expand. Then change this value in the didSelect proxy method and reload the line. Judge isExpand in the heightForRow agent method and return the two heights (initial height and full height) that Xiaoming has calculated in the FeedCellModel. I won’t show you the code. Great. Soon, the first edition is online.

Second need: Like

In the second version of the plan, the product manager designed a “like” feature, as shown here

var favorBtn: UIButton
var favorLable: UILabel

func init(frame: CGRect) {
    /// Add a few lines of favorBtn and favorLable
    }

func favorClick(_ sender: Any) {
    /// Request the favorLable here and re-assign the favorLable value
}
Copy the code

Then go to the FeedCellModel and add the height of the “like” control where it already calculated the height. Good. So far, both requirements have been completed very quickly and perfectly.

Third requirement: picture presentation

Only text can be too monotonous, as the saying goes, there is no picture to say jbšŸ˜‚, the product manager designed a picture display, demand as shown in the figure

According to the design drawing, the picture is displayed in nine grids, and it needs to be placed between content and likes. At this time, Xiao Ming feels a little tricky, and thinks that there are a lot of codes to change. If UIButton is used one by one, it is very annoying to calculate frame and constraint, so I don’t want to write it at all. Have set up and view the constraints of the up and down, according to the picture Settings hidden, in FeedCellModel quantity to calculate the height according to the pictures, such as if also can complete, change places can also accept, but I have already can’t accept, so there is no sample code), as a result, third edition and pleasant finish.

class FeedCell: UITableViewCell {
    var imageCollectionView: UICollectionView
}
Copy the code

Fourth requirement: Review presentation

The product manager designs a new requirement that displays all comments and allows the sender to delete inappropriate comments. Looks like we’re getting more social. After a few seconds, Ming thought about how to create a tableView in the FeedCell, calculate the height in advance, recalculate the height in the commentCell delete button click event and delete the cell. Or encapsulate commentviews, or calculate the height in advance, add the corresponding number of commentviews to the data, delete one and recalculate the height. Either way, it’s a lot of work.

class CommentTableView: UIView {
    var tableView: UITableView
    var comments: [Comment] {
        didSet {
            tableView.reloadData()
        }
    }
    func onDeleteClick(_ sender: UIBUtton) {
       // The agent goes out to process the deletion comment event}}class FeedCell: UITableViewCell {
    var commentTable: CommentTableView
    func setViewModel(_ viewModel: FeedCellModel) {
        // Adjust the height constraint of commentTable to pass data into commentTable rendering comment list}}Copy the code

It took Xiao Ming two days to finish the requirement before the weekend. However, he decided to spend some time on weekends to find a refactoring solution. After all, the product manager had a lot of ideas, and it would be possible to add video playback, voice playback, and even other types of data such as ads into the feed stream. The FeedCell and TableView would become more and more difficult to maintain. Calculating height will also be difficult and all-encompassing.

During his free weekend, Ming went to Github and found his savior, IGListKit.

IGListKit

IGListKit is a data-driven UI framework based on UICollectionView created by Instagram. Currently there is 9K + Star on Github, which is fully utilized on Instagram App. Students who can climb the wall can go to experience it. Take a look at the Instagram experience and imagine what it would have been like if those pages had been implemented by Ming the old-fashioned way. Suffice it to say, with IGListKit, any list-like page UI build will be so easy!

First, a few basic concepts in IGList are introduced.

ListAdapter

An adapter that unites the dataSource and delegate of the CollectionView and is responsible for providing the CollectionView data, updating the UI, and calling back various proxy events.

ListSectionController

A section Controller is a controller object that abstracts the sections of the UICollectionView, specifying a data object that is responsible for configuring and managing cells in a section of the CollectionView. The concept is similar to a View-model for configuring a view: The data object is the View-Model, the cell is the View, and the section Controller is the glue between the two.

The specific relationship is shown in the figure below

Over the weekend, Ming studied IGListKit carefully. Thanks to the ease of use of IGListKit, and of course Ming’s intelligence, he decided to rebuild the feed next week.

As soon as He got to work on Monday, Ming began rewriting the above requirements using IGListKit.

Preparation: Layout the collectionView and bind the adapter

BaseListViewController.swift

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

Create an adapter and match the collectionView to it

// The data model needs to implement ListDiffable protocol
var objects: [ListDiffable] = [ListDiffable] ()lazy var adapter: ListAdapter = {
        let adapter = ListAdapter(updater: ListAdapterUpdater(), viewController: self)
    return adapter
    }()
override func viewDidLoad(a) {
        super.viewDidLoad()
        view.addSubview(collectionView)
        adapter.collectionView = collectionView
        adapter.dataSource = self
    }
Copy the code

Implement the ListAdapterDataSource protocol to provide data

/// return all data to be displayed in the collectionView
func objects(for listAdapter: ListAdapter)- > [ListDiffable] {
       return objects
    }
// return the sectionController corresponding to each data,
    func listAdapter(_ listAdapter: ListAdapter, sectionControllerFor object: Any) -> ListSectionController {
    //ListSectionController is an abstract base class. You can't use it directly. You have to subclass it.
        return ListSectionController()}/// Placeholder view to display when data is empty
    func emptyView(for listAdapter: ListAdapter) -> UIView? {
        return nil
    }
Copy the code

In order to clearly compare the changes to each requirement, the demo has a ViewController for each requirement and a base class to create the collectionView and Adapter.

First requirement: display username and text content

Prepare two cells

class UserInfoCell: UICollectionViewCell {

    @IBOutlet weak var avatarView: UIImageView!
    @IBOutlet weak var nameLabel: UILabel!
    public var onClickArrow: ((UserInfoCell) - >Void)?
    override func awakeFromNib(a) {
        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(a) {
        super.awakeFromNib()
        // Initialization code
    }
   static func lineHeight(a) -> 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
    }
}
Copy the code

Prepare sectionController, one cell for each sectionController. This is just one way to do it, but there’s another way (just one 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(a) -> 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 letcell = 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}}Copy the code
class ContentSectionController: ListSectionController {
    var object: Feed!
    var expanded: Bool = false

    override func numberOfItems(a) -> Int {
        ifobject.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 letcell = 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)}}Copy the code

Get the data in the ViewController, implement the data source protocol

class FirstListViewController: BaseListViewController {
override func viewDidLoad(a) {
        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
    }
}
Copy the code

Here USES a class ListStackedSectionController framework, it is to manage the sectionController. Here I look upon each data corresponding to the larger group, each cell according to the data as a group, ListStackedSectionController is big group, it will be from top to bottom in order of sectionControllers array liezi sectionController, It’s kind of like UIStackView.

The first requirement has been implemented, which seems to be more code than the original implementation ah, which becomes simpler, don’t worry, continue to read.

Second need: Like

The original idea was to modify the FeedCell, add new controls, and recalculate the height in the viewModel, which violates the open and close principle of object-oriented design. So how to do now? We just add a FavorCell and a FavorSectionController, without touching the existing 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) } funcbindViewModel(_ viewModel: Any) {
        guard let viewModel = viewModel as? FavorCellModel else { return }
        self.viewModel = viewModel
        self.favorBtn.isSelected = viewModel.isFavor
        self.nameLabel.text = viewModel.favorNum
    }
}
Copy the code
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 {
        letwidth: CGFloat! = collectionContext? .containerSize(for: self).width
        return CGSize(width: width, height: 65)
    }
    override func cellForItem(at index: Int) -> UICollectionViewCell {
        guard letcell = 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()
            letorigin: 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
    }
}
Copy the code

Just re-implement the data source method in the 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
    }
Copy the code

Look, just need a new one in ListStackedSectionController FavorSectionController, can complete the requirements.

Third: picture display

9 grid picture display, with UICollectionView is the simplest way to achieve.

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 { guardlet 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)
    }
}

Copy the code
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 {
            return0}return 1
    }

    override func sizeForItem(at index: Int) -> CGSize {
        letwidth: 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 letcell = 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
    }
}
Copy the code

With the same before operation, ImageSectionController added to go in the ListStackedSectionController šŸ‘Œ. Oh, wait, the image area seems to be below the content and above the favors. so put the ImageSectionController between the ContentSectionController and the 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
    }
Copy the code

This already shows the absolute advantages of IGListKit over traditional implementations, high flexibility and extensibility.

If the product manager wants to put the image above the content or under the like, she just needs to change the position of ImageSectionController. She can change it any way she wants, or even change it back to the original demand. Now she can deal with šŸ˜ easily.

Fourth requirement: reviews

The comment area is treated as a single group, and the number of cells in this group is not determined. Cellmodels are generated and configured based on the number of comments in the Feed.

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 {
        letwidth: CGFloat! = collectionContext? .containerSize(for: self).width
        return CGSize(width: width, height: 44)
    }

    override func cellForItem(at index: Int) -> UICollectionViewCell {
        guard letcell = 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
                letdeleteIndex: 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
    }
}
Copy the code

The CommentSectionController handles the deletion of the commentCell button by proxy, deletes the cellModels array in the closure, and then calls IGListKit’s batch update operation to delete the cell in the specified location. Finally the same operation, in ListStackedSectionController plus a is ok again.

Ming refactored the page in one day and was no longer afraid of the bizarre requirements from the product manager. Xiao Ming decided to leave work on time today and have a nice meal.

ListDiffable

ListDiffable protocol, which is part of the core Diff algorithm of IGListKit, can only be used if ListDiffable protocol is implemented. This algorithm is an algorithm to calculate the relationship between the change of data before and after the new and old arrays. The time complexity is O(n). Is one of the features of IGListKit. Paul Heckel’s algorithm of A technique for Classical differences between Files is used.

conclusion

So far, we use the sectionController + ListStackedSectionController method perfect to achieve the four requirements. This is I recommend the implementation of the way, but is not the only, there are two way ListBindingSectionController (recommended) and only need a ListSectionController can realize, has been implemented in the demo, not posted here, You can see that in the demo.

IGListKit also makes it very easy to implement multilevel lists and multilevel lists with multiple selection.

Of course, one thing can not only have advantages, IGListKit also has disadvantages, so far I use the experience, the main several may be a little pit.

  • Autolayout support is not good. Basically, we need to calculate the size of the cell by ourselves, but IGListKit divides the large cell into small cells, and the calculation of the height has become much easier. This shortcoming can be ignored

  • Because it is based on UICollectionView, there is no sliding feature of UITableView, which is actually mentioned in the issue, but it is not the category that IGListKit should consider (official staff replied in this way), so far I think of two solutions. You can either do it yourself or use a third-party library to do the sliding of the UICollectionViewCell, or you can nest a UITableView into a UICollectionViewCell, which may need to be encapsulated.

I believe that see here, you can clearly feel IGListKit powerful ability, it fully shows OOP high cohesion and low coupling ideas, with high ease of use, scalability, maintainability, embodies the whole into a small, complex to simplify the philosophy.

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