Post

Visitor Pattern|Enhance TableView Readability and Extensibility

Improve TableView management with Visitor Pattern to simplify code maintenance and enable flexible feature expansion, boosting development efficiency and scalability.

Visitor Pattern|Enhance TableView Readability and Extensibility

点击这里查看本文章简体中文版本。

點擊這裡查看本文章正體中文版本。

This post was translated with AI assistance — let me know if anything sounds off!


Visitor Pattern in TableView

Using Visitor Pattern to Improve TableView Readability and Extensibility

Photo by [Alex wong](https://unsplash.com/@killerfvith?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Alex wong

Introduction

Following the previous article “Visitor Pattern in Swift” which introduced the Visitor pattern and a simple practical use case, this article will present another real-world application in iOS development.

Use Case Scenario

To develop a dynamic wall feature, multiple types of blocks need to be dynamically combined and displayed.

Taking StreetVoice’s activity feed as an example:

As shown in the above image, the dynamic wall is composed of various types of blocks dynamically combined:

  • Type A: Event Updates

  • Type B: Tracking Recommendations

  • Type C: New Song Updates

  • Type D: New Album Updates

  • Type E: New Tracking Updates

  • Type …. More

The types are expected to increase over time as features are iterated.

Question

Without any architectural design, the code might look like this:

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
42
43
44
45
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let row = datas[indexPath.row]
    switch row.type {
    case .invitation:
        let cell = tableView.dequeueReusableCell(withIdentifier: "invitation", for: indexPath) as! InvitationCell
        // config cell with viewObject/viewModel...
        return cell
    case .newSong:
        let cell = tableView.dequeueReusableCell(withIdentifier: "newSong", for: indexPath) as! NewSongCell
        // config cell with viewObject/viewModel...
        return cell
    case .newEvent:
        let cell = tableView.dequeueReusableCell(withIdentifier: "newEvent", for: indexPath) as! NewEventCell
        // config cell with viewObject/viewModel...
        return cell
    case .newText:
        let cell = tableView.dequeueReusableCell(withIdentifier: "newText", for: indexPath) as! NewTextCell
        // config cell with viewObject/viewModel...
        return cell
    case .newPhotos:
        let cell = tableView.dequeueReusableCell(withIdentifier: "newPhotos", for: indexPath) as! NewPhotosCell
        // config cell with viewObject/viewModel...
        return cell
    }
}

func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
    let row = datas[indexPath.row]
    switch row.type {
    case .invitation:
        if row.isEmpty {
            return 100
        } else {
            return 300
        }
    case .newSong:
        return 100
    case .newEvent:
        return 200
    case .newText:
        return UITableView.automaticDimension
    case .newPhotos:
        return UITableView.automaticDimension
    }
}
  • Hard to Test: It is difficult to test which Type corresponds to which logical output

  • Difficult to scale and maintain: Whenever a new Type needs to be added, this ViewController must be modified; cellForRow, heightForRow, willDisplay… are scattered across different functions, making it easy to forget or make mistakes when updating.

  • Hard to read: All logic is placed in the View

Visitor Pattern Solution

Why?

Here is the organized object relationship, as shown in the diagram below:

We have many types of DataSources (ViewObjects) that need to interact with various types of operators. This is a typical example of Visitor Double Dispatch.

How?

To simplify the demo code, the design now uses PlainTextFeedViewObject for plain text feeds, MemoriesFeedViewObject for daily memories, and MediaFeedViewObject for image feeds.

The architecture diagram applying the Visitor Pattern is as follows:

First, define the Visitor interface. This interface abstracts the types of DataSource that the operator can accept:

1
2
3
4
5
6
7
protocol FeedVisitor {
    associatedtype T
    func visit(_ viewObject: PlainTextFeedViewObject) -> T?
    func visit(_ viewObject: MediaFeedViewObject) -> T?
    func visit(_ viewObject: MemoriesFeedViewObject) -> T?
    //...
}

Each operator implements the FeedVisitor interface:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct FeedCellVisitor: FeedVisitor {
    typealias T = UITableViewCell.Type
    
    func visit(_ viewObject: MediaFeedViewObject) -> T? {
        return MediaFeedTableViewCell.self
    }
    
    func visit(_ viewObject: MemoriesFeedViewObject) -> T? {
        return MemoriesFeedTableViewCell.self
    }
    
    func visit(_ viewObject: PlainTextFeedViewObject) -> T? {
        return PlainTextFeedTableViewCell.self
    }
}

Implement ViewObject <-> UITableViewCell mapping.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct FeedCellHeightVisitor: FeedVisitor {
    typealias T = CGFloat
    
    func visit(_ viewObject: MediaFeedViewObject) -> T? {
        return 30
    }
    
    func visit(_ viewObject: MemoriesFeedViewObject) -> T? {
        return 10
    }
    
    func visit(_ viewObject: PlainTextFeedViewObject) -> T? {
        return 10
    }
}

Implement ViewObject <-> UITableViewCell Height Mapping.

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
struct FeedCellConfiguratorVisitor: FeedVisitor {
    
    private let cell: UITableViewCell
    
    init(cell: UITableViewCell) {
        self.cell = cell
    }
    
    func visit(_ viewObject: MediaFeedViewObject) -> Any? {
        guard let cell = cell as? MediaFeedTableViewCell else { return nil }
        // cell.config(viewObject)
        return nil
    }
    
    func visit(_ viewObject: MemoriesFeedViewObject) -> Any? {
        guard let cell = cell as? MediaFeedTableViewCell else { return nil }
        // cell.config(viewObject)
        return nil
    }
    
    func visit(_ viewObject: PlainTextFeedViewObject) -> Any? {
        guard let cell = cell as? MediaFeedTableViewCell else { return nil }
        // cell.config(viewObject)
        return nil
    }
}

How to Configure the Mapping Between ViewObject <-> Cell.

When support for a new DataSource (ViewObject) is needed, simply add an additional method to the FeedVisitor interface and implement the corresponding logic in each operator.

Binding DataSource (ViewObject) with Operators:

1
2
3
protocol FeedViewObject {
    @discardableResult func accept<V: FeedVisitor>(visitor: V) -> V.T?
}

Interface for ViewObject Binding Implementation:

1
2
3
4
5
6
7
8
9
10
struct PlainTextFeedViewObject: FeedViewObject {
    func accept<V>(visitor: V) -> V.T? where V : FeedVisitor {
        return visitor.visit(self)
    }
}
struct MemoriesFeedViewObject: FeedViewObject {
    func accept<V>(visitor: V) -> V.T? where V : FeedVisitor {
        return visitor.visit(self)
    }
}

Implementation in UITableView:

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
final class ViewController: UIViewController {

    @IBOutlet weak var tableView: UITableView!
    
    private let cellVisitor = FeedCellVisitor()
    
    private var viewObjects: [FeedViewObject] = [] {
        didSet {
            viewObjects.forEach { viewObject in
                let cellName = viewObject.accept(visitor: cellVisitor)
                tableView.register(cellName, forCellReuseIdentifier: String(describing: cellName))
            }
        }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        tableView.delegate = self
        tableView.dataSource = self
        
        viewObjects = [
            MemoriesFeedViewObject(),
            MediaFeedViewObject(),
            PlainTextFeedViewObject(),
            MediaFeedViewObject(),
            PlainTextFeedViewObject(),
            MediaFeedViewObject(),
            PlainTextFeedViewObject()
        ]
        // Do any additional setup after loading the view.
    }
}

extension ViewController: UITableViewDataSource {
    func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewObjects.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let viewObject = viewObjects[indexPath.row]
        let cellName = viewObject.accept(visitor: cellVisitor)
        
        let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: cellName), for: indexPath)
        let cellConfiguratorVisitor = FeedCellConfiguratorVisitor(cell: cell)
        viewObject.accept(visitor: cellConfiguratorVisitor)
        return cell
    }
}

extension ViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        let viewObject = viewObjects[indexPath.row]
        let cellHeightVisitor = FeedCellHeightVisitor()
        let cellHeight = viewObject.accept(visitor: cellHeightVisitor) ?? UITableView.automaticDimension
        return cellHeight
    }
}

Results

  • Test: Complies with the Single Responsibility Principle, allowing testing of each data point for every operator separately

  • Extension and Maintenance: When support for a new DataSource (ViewObject) is required, just add a method to the Visitor protocol and implement it in the corresponding Visitor classes. When adding a new operator, simply create a new class to implement it.

  • Reading: Browsing through each activity object clearly shows the composition logic of the various Views on the entire page

Complete Project

Murmur…

2022/07 Article written during a mental low period. Please kindly forgive any inadequate descriptions or errors!

Further Reading

If you have any questions or feedback, feel free to contact me.


Buy me a beer

This post was originally published on Medium (View original post), and automatically converted and synced by ZMediumToMarkdown.

Improve this page on Github.

This post is licensed under CC BY 4.0 by the author.