Visitor Pattern in TableView
Enhancing the readability and extensibility of TableView using the Visitor Pattern
Photo by Alex wong
Introduction
Following the previous article on “Visitor Pattern in Swift” introducing the Visitor pattern and a simple practical application scenario, this article will discuss another practical application in iOS development.
Scenario
Developing a dynamic wall feature where various types of blocks need to be dynamically combined and displayed.
Taking StreetVoice’s dynamic wall as an example:
As shown in the image above, the dynamic wall is composed of various types of blocks dynamically combined, including:
- Type A: Activity updates
- Type B: Follow recommendations
- Type C: New song updates
- Type D: New album updates
- Type E: New tracking updates
- Type … and more
More types are expected to be added in the future with iterative functionality.
Issue
Without any architectural design, the code may 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
}
}
- Difficult to test: It is challenging to test the corresponding logic output for each type.
- Difficult to extend and maintain: Whenever a new type needs to be added, modifications are required in this ViewController; cellForRow, heightForRow, willDisplay… scattered across different functions, making it prone to forgetting to update or making mistakes.
- Difficult to read: All logic is within the View itself.
Visitor Pattern Solution
Why?
Organized the object relationships as shown in the figure below:
We have many types of DataSource (ViewObject) that need to interact with multiple types of operators, which is a very typical Visitor Double Dispatch.
How?
To simplify the Demo Code, we will use PlainTextFeedViewObject
for plain text feed, MemoriesFeedViewObject
for daily memories, and MediaFeedViewObject
for image feed to demonstrate the design.
The architecture diagram applying the Visitor Pattern is as follows:
First, define the Visitor interface, which abstractly declares the types of DataSource that operators 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?
//...
}
Implement the FeedVisitor
interface for each operator:
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 the mapping between ViewObject <-> UITableViewCell.
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 the mapping between ViewObject <-> UITableViewCell Height.
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
}
}
Implement ViewObject <-> Cell how to Config mapping.
When you need to support a new DataSource (ViewObject), just add a new method in the FeedVisitor interface, and implement the corresponding logic in each operator.
DataSource (ViewObject) binding with operators:
1
2
3
protocol FeedViewObject {
@discardableResult func accept<V: FeedVisitor>(visitor: V) -> V.T?
}
ViewObject implementation binding interface:
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
}
}
Result
- Testing: Complies with the Single Responsibility Principle, can test each data point for each operator
- Scalability and Maintenance: When needing to support a new DataSource (ViewObject), just need to extend an interface in the Visitor protocol, and implement it in the individual operator Visitor. When needing to extract a new operator, just need to create a new Class for implementation.
- Readability: Just need to browse through each operator object to understand the composition logic of each View on the entire page.
Complete Project
Murmur…
Article written during the low period of thinking in July 2022. If there are any inadequacies or errors in the content, please forgive me!
Further Reading
- Practical application record of Design Patterns — In WKWebView with Builder, Strategy & Chain of Responsibility Pattern
- Practical application record of Design Patterns
- Visitor Pattern in Swift (Share Object to XXX Example)
Feel free to contact me for any questions or feedback.
===
===
This article was first published in Traditional Chinese on Medium ➡️ View Here