This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We start implementing a bento layout using two different approaches.

00:06 Today, we want to try building a bento box layout. We see this layout a lot in Apple's presentations, where various features of a product are displayed in a grid-like layout with nested columns and rows.

00:28 We'll start with a simple approach, but later on, we'll make use of some new SwiftUI APIs that let us iterate over subviews.

Split View

00:47 The easiest way to create the layout is by writing a container view that places its contents in either an HStack or a VStack, depending on the context. For now, let's just use an HStack, so we can start building a sample layout in our ContentView:

struct Split<Content: View>: View {
    @ViewBuilder var content: Content
    var body: some View {
        HStack {
            content
        }
    }
}

struct ContentView: View {
    var body: some View {
        Split {
            Split {
                Color.red
                Split {
                    Color.blue
                    Color.yellow
                }
            }
            Color.green
        }
    }
}

01:46 To switch between horizontal and vertical splits, we'll need an environment value. This can now be written very succinctly, thanks to the new @Entry macro:

extension EnvironmentValues {
    @Entry var direction: Axis = .vertical
}

02:02 If we expand the macro, we can see it generates an environment key and a getter and a setter for our property — i.e. code we've had to manually write hundreds of times before:

extension EnvironmentValues {
    @Entry var direction: Axis = .vertical
    {
        get {
            self[__Key_direction.self]
        }
        set {
            self[__Key_direction.self] = newValue
        }
    }
    private struct __Key_direction: SwiftUI.EnvironmentKey {
        typealias Value = Axis
        static let defaultValue: Value = .vertical
    }
}

02:24 In Split, we read the environment value to decide between a horizontal and a vertical layout:

struct Split<Content: View>: View {
    @Environment(\.direction) var axis
    @ViewBuilder var content: Content
    var body: some View {
        let layout = axis == .horizontal ? AnyLayout(HStackLayout()) : AnyLayout(VStackLayout())
        layout {
            content
        }
    }
}

02:52 For child views of Split, we switch to the direction other than the current one by changing the environment value:

extension Axis {
    var other: Self {
        self == .horizontal ? .vertical : .horizontal
    }
}

struct Split<Content: View>: View {
    @Environment(\.direction) var axis
    @ViewBuilder var content: Content
    var body: some View {
        let layout = axis == .horizontal ? AnyLayout(HStackLayout()) : AnyLayout(VStackLayout())
        layout {
            content
        }
        .environment(\.direction, axis.other)
    }
}

03:23 We can't directly pass the new direction to the next Split view, because we don't know that there's another Split view — we just have the content view. That's why we use the environment to pass the updated axis down the view tree.

03:42 This works, and we can play around with the sample view to see what kinds of layouts we can create. For example, we can add a teal rectangle to split the top-right view into three rows:

Or we can move the teal rectangle to the second nested Split view to split it into three columns:

We can basically split any view in any number of parts:

03:59 Because all our views are completely flexible, this fills up the available space. But we could also restrict, say, the blue rectangle to be 20 points high, and it'll still work:

04:32 This approach lets us create a bento box very easily. But in some cases, we might have to separate the layout from the content. For example, if we have an array of items we want to display, we might want to compute a layout for the items first, and then list the views for the items using a ForEach.

Separate Layout Definition

05:09 So our next step is to think of an approach in which we can separate the layout definition and the content views. We define our layout with a struct that can nest recursively, which we name SplitItem:

struct SplitItem {
    var children: [SplitItem] = []
}

06:14 Then, we can try to define the layout of our sample view as a SplitItem. We start out by splitting the top-level view into three rows. The first row is split into two columns, and the second of these columns is split into two rows:

let sample = SplitItem(children: [
    .init(children: [
        .init(),
        .init(children: [
            .init(),
            .init(),
        ])
    ]),
    .init(),
    .init(),
])

Bento View

07:37 With the sample layout defined, we now need something to combine it with a list of views, although, for now, we'll just render blue rectangles as placeholder views, so that we don't have to pass actual subviews around. We write a view that takes a SplitItem definition:

struct Bento: View {
    var split: SplitItem

    var body: some View {
        
    }
}

08:53 We start with a VStack container, and then we iterate over the children of split. For each child, we insert another Bento view:

struct Bento: View {
    var split: SplitItem

    var body: some View {
        VStack {
            ForEach(0..<split.children.count, id: \.self) { idx in
                Bento(split: split.children[idx], axis: axis.other)
            }
        }
    }
}

10:11 This works for the most part, except we're only splitting the views vertically. We can see this if we render a blue placeholder view for each leaf node — i.e. a SplitItem without children:

struct Bento: View {
    var split: SplitItem

    var body: some View {
        VStack {
            if split.children.isEmpty {
                Color.blue
            } else {
                ForEach(0..<split.children.count, id: \.self) { idx in
                    Bento(split: split.children[idx], axis: axis.other)
                }
            }
        }
    }
}

10:59 We add a Bento view to ContentView, passing in the sample layout:

struct ContentView: View {
    var body: some View {
        Bento(split: sample)
    }
}

11:14 This looks good; we just need to flip the axis with each recursion. We add an axis property to Bento, giving it a default value of .vertical. To the nested Bento views, we pass the other axis, using the helper from before. Finally, we change the VStack container into a dynamic layout, just like we did in the Split view earlier:

struct Bento: View {
    var split: SplitItem
    var axis: Axis = .vertical

    var body: some View {
        let layout = axis == .vertical ? AnyLayout(VStackLayout()) : AnyLayout(HStackLayout())
        layout {
            if split.children.isEmpty {
                Color.blue
            } else {
                ForEach(0..<split.children.count, id: \.self) { idx in
                    Bento(split: split.children[idx], axis: axis.other)
                }
            }
        }
    }
}

12:42 The next step is looping over a collection of views and figuring out how much of the collection we need to pass on to each nested layout. It'll be tricky, but we should be able to make it work in the next episode.

Resources

  • Sample Code

    Written in Swift 6.0

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

160 Episodes · 55h34min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes