You are currently viewing How I Created a DSL for Diffable Section Snapshot using Result Builders

How I Created a DSL for Diffable Section Snapshot using Result Builders

If you are like me, have been using a diffable data source section snapshot for quite some time, I am sure you will notice that the code to construct a section snapshot is actually quite difficult to reason about. The append(_:) and append(_:to:) API doesn’t really show us the hierarchical data structure it represents.

With the release of result builders in Swift 5.4, it makes me wonder is it possible to create a domain-specific language (DSL) that can help us to:

  1. Construct a section snapshot easily.
  2. Visualize the section snapshot’s hierarchical data structure.

After a few days of poking around, I managed to create a simple DSL that fits both of these criteria, and I would like to show you how I have done it in this article.

Note:

This article does require you to have basic knowledge on result builders and list building with diffable data source. If you are not familiar with these 2 topics, you can catch up by reading the following articles:

Swift Result Builders: The Basics You Need to Know!

Building an Expandable List Using UICollectionView: Part 1


The Strategy

Our ultimate goal is to be able to construct a section snapshot, therefore we will need an array that represents the section snapshot’s hierarchical structure.

To do so, we will need to create a result builder that takes an array of the snapshot’s item identifier type as its component. Let’s say the item identifier type is ListItem, then our section snapshot builder will look like this:

@resultBuilder
struct SectionSnapshotBuilder {

    static func buildBlock(_ components: [ListItem]...) -> [ListItem] {
        // ... ...
    }
}

We will implement the section snapshot builder so that it will group all the given components into an array of ListItem that represent the desired hierarchical structure.

Once all the components are grouped, we then utilize the buildFinalResult(_:) builder method to construct a section snapshot using all the grouped components.

static func buildFinalResult(_ component: [ListItem]) -> NSDiffableDataSourceSectionSnapshot<ListItem> {
    // Construct a section snapshot using `component`...
}

With that in mind, we can now proceed to implement the section snapshot builder.


Supporting Single-Level Data Structure

To take things one step at a time, let’s focus on making our section snapshot builder able to support single-level data structure for now. Here’s the list that we are going to build:

single-level list build using section snapshot builder DSL
Single-level list

Before proceed any further, let’s define a section snapshot item identifier type that holds a title and image variable:

struct ListItem: Hashable {
    
    let title: String
    let image: UIImage?
    
    init(_ title: String) {
        self.title = title
        self.image = UIImage(systemName: title)
    }
}

Next up, let’s define a protocol named ListItemGroup. We will use this protocol to “trick” the compiler so that our section snapshot builder is able to take both ListItem and an array of ListItem as components.

protocol ListItemGroup {
    var items: [ListItem] { get }
}

extension Array: ListItemGroup where Element == ListItem {
    var items: [ListItem] { self }
}

extension ListItem: ListItemGroup {
    var items: [ListItem] { [self] }
}

The reason for doing so is to make both the section snapshot builder and its call site easier to read and reason about(you will see that in action shortly). If you would like to learn more about this trick, I highly recommend you to check out this article: Result builders in Swift explained with code examples.

With both ListItem and ListItemGroup in place, we can now implement the section snapshot builder like so:

@resultBuilder
struct SectionSnapshotBuilder {
    
    static func buildBlock(_ components: ListItemGroup...) -> [ListItem] {
        return components.flatMap { $0.items }
    }
}

The above SectionSnapshotBuilder is taking ListItemGroup as input and the final output is [ListItem], but what we really want as output is NSDiffableDataSourceSectionSnapshot. Therefore, the last thing that we need to do is implement the buildFinalResult(_:) builder method:

static func buildFinalResult(_ component: [ListItem]) -> NSDiffableDataSourceSectionSnapshot<ListItem> {

    var sectionSnapshot = NSDiffableDataSourceSectionSnapshot<ListItem>()
    sectionSnapshot.append(component)
    return sectionSnapshot
}

With that, we have created a fully functional section snapshot builder that supports single-level data structure. Let’s use it to construct a list that you saw at the beginning of this section:

@SectionSnapshotBuilder func singleLevelSectionSnapshot() -> NSDiffableDataSourceSectionSnapshot<ListItem> {
    
    ListItem("Cell 1")
    ListItem("Cell 2")
    ListItem("Cell 3")
    ListItem("paperplane.fill")
    ListItem("doc.text")
    ListItem("book.fill")
}

let sectionSnapshot = singleLevelSectionSnapshot()
dataSource.apply(sectionSnapshot, to: .main)

As you can see, by using the SectionSnapshotBuilder, constructing a section snapshot is just a matter of sequencing the ListItems based on our needs.

In the next section, we will look at how to make the SectionSnapshotBuilder support multi-level data structure. Being able to construct a section snapshot with multi-level data structure is where the SectionSnapshotBuilder really shines.


Supporting Multi-Level Data Structure

Updating the Builder

In order to make the section snapshot support multi-level data structure, we must first update the ListItem struct to hold another variable called children:

struct ListItem: Hashable {

    let title: String
    let image: UIImage?
    var children: [ListItem]

    init(_ title: String, children: [ListItem] = []) {
        self.title = title
        self.image = UIImage(systemName: title)
        self.children = children
    }
}

With the children variable, we are now able to create a parent-child relationship between each ListItem like this:

ListItem("Parent", children: [
    ListItem("Child 1"),
    ListItem("Child 2"),
    ListItem("Child 3"),
])

Thus enabling us to leverage the SectionSnapshotBuilder to create a ListItem object with multiple levels of hierarchical structure. To do that, we can create a static function in SectionSnapshotBuilder that takes a ListItem as parent and a SectionSnapshotBuilder that returns an array of ListItem as children:

static func parent(_ parent: ListItem,
                   @SectionSnapshotBuilder children: () -> [ListItem]) -> ListItem {
    return ListItem(parent.title, children: children())
}

Notice how we can pass around SectionSnapshotBuilder as a function parameter and use it as the ListItem‘s children. Doing so allow us to represent the section snapshot data structure in a hierarchical way like this:

SectionSnapshotBuilder.parent(ListItem("Parent A")) {
    ListItem("Child A-1")
    ListItem("Child A-2")
    ListItem("Child A-3")
}

SectionSnapshotBuilder.parent(ListItem("Parent B")) {
    ListItem("Child B-1")
    ListItem("Child B-2")
    ListItem("Child B-3")
}

But before we can really use this in our SectionSnapshotBuilder, we must add the following result building method into the SectionSnapshotBuilder to make it able to output an array of ListItem as the final result, which is basically what children is doing.

static func buildFinalResult(_ component: [ListItem]) -> [ListItem] {
    return component
}

With all these in place, all that remains is to update the buildFinalResult(_:) builder method to construct a section snapshot using all the ListItems with hierarchical structure:

static func buildFinalResult(_ component: [ListItem]) -> NSDiffableDataSourceSectionSnapshot<ListItem> {
    
    var sectionSnapshot = NSDiffableDataSourceSectionSnapshot<ListItem>()
    createSection(nil, with: component, for: &sectionSnapshot)
    return sectionSnapshot
}

As you can see, we are calling a createSection() function to convert the given component into a section snapshot. It is basically a recursive function that goes through each and every ListItem in component, and constructs the section snapshot accordingly. Here’s the implementation:

private static func createSection(_ parent: ListItem?,
                                  with children: [ListItem],
                                  for sectionSnapshot: inout NSDiffableDataSourceSectionSnapshot<ListItem>) {
    
    for child in children {

        if child.children.count > 0 {
            // Children available

            if parent == nil {
                // Append first level items with children
                sectionSnapshot.append([child])
            }
            sectionSnapshot.expand([child])
            sectionSnapshot.append(child.children, to: child)
            
            createSection(child, with: child.children, for: &sectionSnapshot)
            
        } else if parent == nil {
            // Append first level items without children
            sectionSnapshot.append([child])
        }
    }
}

Builder in Action

With that, our section snapshot builder is now able to support multi-level data structures. To see it in action, let’s use it to build the following list:

multi-level list build using section snapshot builder DSL
Multi-level list

By using the parent(_:children:) function that we created just now, we can construct the data structure like so:

@SectionSnapshotBuilder func multiLevelSectionSnapshot() -> NSDiffableDataSourceSectionSnapshot<ListItem> {
    
    ListItem("Cell 1")
    ListItem("Cell 2")
    ListItem("Cell 3")
    
    SectionSnapshotBuilder.parent(ListItem("Cell 4")) {
        
        ListItem("a.circle")
        ListItem("b.square")
        
        SectionSnapshotBuilder.parent(ListItem("Cell 5")) {
            ListItem("c.circle.fill")
        }
    }
}

let sectionSnapshot = multiLevelSectionSnapshot()
dataSource.apply(sectionSnapshot, to: .main)

What I really like about this is that it is able to visually represent a section snapshot’s hierarchical structure, making the code to construct a section snapshot extremely easy to read and write.

We can even further simplify the above code by using typealias:

typealias B = SectionSnapshotBuilder
@SectionSnapshotBuilder func multiLevelSectionSnapshot() -> NSDiffableDataSourceSectionSnapshot<ListItem> {
    
    ListItem("Cell 1")
    ListItem("Cell 2")
    ListItem("Cell 3")
    
    B.parent(ListItem("Cell 4")) {
        
        ListItem("a.circle")
        ListItem("b.square")
        
        B.parent(ListItem("Cell 5")) {
            ListItem("c.circle.fill")
        }
    }
}

Pretty cool isn’t it? 😎


Supporting Dynamic Data Structure

As good as it might seem, our section snapshot builder is pretty useless if it can only support static data structure. What I mean by static is that the section snapshot’s data structure must be fixed at development time and cannot be constructed during runtime.

Fortunately, making it able to support dynamic data structure is pretty straightforward. All we need to do is add in the following result building methods:

// Support `for-in` loop
static func buildArray(_ components: [ListItemGroup]) -> [ListItem] {
    return components.flatMap { $0.items }
}

// Support `if` block
static func buildOptional(_ component: [ListItemGroup]?) -> [ListItem] {
    return component?.flatMap { $0.items } ?? []
}

// Support `if-else` block (if)
static func buildEither(first component: [ListItemGroup]) -> [ListItem] {
    return component.flatMap { $0.items }
}

// Support `if-else` block (else)
static func buildEither(second component: [ListItemGroup]) -> [ListItem] {
    return component.flatMap { $0.items }
}

This will make our section snapshot builder able to understand an if block, if-else block and for-in loop.

To put it to a test, let’s say we are given the following array of SFSymbol names:

let symbolNames = [
    "a.circle",
    "a.circle.fill",
    "a.square",
    "a.square.fill",
    
    "b.circle",
    "b.circle.fill",
    "b.square",
    "b.square.fill",
    
    "c.circle",
    "c.circle.fill",
    "c.square",
    "c.square.fill",
]

and we want to build a multi-level list that group all these symbols by shape and fill state like so:

Multi-level list with grouped symbols build using section snapshot builder DSL
Multi-level list with grouped symbols

Notice that there is also a right navigation button that allows us to change the ordering of the symbols from ascending to descending and vice versa.

With all those result building methods in place, all we need to do is create an instance of SectionSnapshotBuilder that takes the symbol names as input and processes them accordingly.

typealias B = SectionSnapshotBuilder
@SectionSnapshotBuilder func generateSectionSnapshot(for symbolNames: [String], descending: Bool) -> NSDiffableDataSourceSectionSnapshot<ListItem> {
    
    B.parent(ListItem("Circle")) {
        
        B.parent(ListItem("Fill (Circle)")) {
            
            // Find all filled circle symbols and transform them to `ListItem`
            let circleFillSymbols = symbolNames.filter {
                $0.contains("circle.fill")
            }.map {
                ListItem($0)
            }
            
            // Make all filled circle symbols as child of `ListItem("Fill (Circle)")`
            if descending {
                Array(circleFillSymbols.reversed())
            } else {
                circleFillSymbols
            }
        }
        
        B.parent(ListItem("No Fill (Circle)")) {
            
            // Find all unfilled circle symbols and transform them to `ListItem`
            let circleNoFillSymbols = symbolNames.filter {
                $0.contains("circle") && !$0.contains("fill")
            }.map {
                ListItem($0)
            }
            
            // Make all unfilled circle symbols as child of `ListItem("No Fill (Circle)")`
            if descending {
                Array(circleNoFillSymbols.reversed())
            } else {
                circleNoFillSymbols
            }
        }
    }
    
    B.parent(ListItem("Square")) {
        
        B.parent(ListItem("Fill (Square)")) {
            
            // Find all filled square symbols and transform them to `ListItem`
            let squareFillSymbols = symbolNames.filter {
                $0.contains("square.fill")
            }.map {
                ListItem($0)
            }
            
            // Make all filled square symbols as child of `ListItem("Fill (Square)")`
            if descending {
                Array(squareFillSymbols.reversed())
            } else {
                squareFillSymbols
            }
        }
        
        B.parent(ListItem("No Fill (Square)")) {
            
            // Find all unfilled square symbols and transform them to `ListItem`
            let squareNoFillSymbols = symbolNames.filter {
                $0.contains("square") && !$0.contains("fill")
            }.map {
                ListItem($0)
            }
            
            // Make all unfilled square symbols as child of `ListItem("No Fill (Square)")`
            if descending {
                Array(squareNoFillSymbols.reversed())
            } else {
                squareNoFillSymbols
            }
        }
    }
}

let sectionSnapshot = generateSectionSnapshot(for: symbolNames, descending: false)
dataSource.apply(sectionSnapshot, to: .main)

Wow, that’s a lot of code!

But if you look closely, the code above is basically doing just 1 thing — Filter the symbol names by different criteria and construct the data structure accordingly.

You can get the full sample code of this article here.

That’s how I created a DSL that can be used to construct a section snapshot while enabling us to easily visualize the section snapshot’s hierarchical data structure. 🥳


Wrapping Up

I am pretty satisfied with the section snapshot builder, using it to construct a section snapshot feels more natural. On top of that, the hierarchical nature of the DSL makes the code that constructs the section snapshot extremely easy to reason about, thus highly increased the maintainability of the code.

Do note that the section snapshot builder does introduce a little bit of overhead because of the conversion of the hierarchical array into a section snapshot. However, with the benefits we get from it, I think the treat-off is worth it.

Do you like the section snapshot builder? Any suggestions for improvement? Feel free to reach out to me on Twitter, I would really like to hear from you.

Thanks for reading. 👨🏻‍💻


Further Readings


👋🏻 Hey!

While you’re still here, why not check out some of my favorite Mac tools on Setapp? They will definitely help improve your day-to-day productivity. Additionally, doing so will also help support my work.

  • Bartender: Superpower your menu bar and take full control over your menu bar items.
  • CleanShot X: The best screen capture app I’ve ever used.
  • PixelSnap: Measure on-screen elements with ease and precision.
  • iStat Menus: Track CPU, GPU, sensors, and more, all in one convenient tool.