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 build a basic split pane view as a basis for profiling animation smoothness.

00:06 Today we'll build our own split view, inspired by a post by Mitchell Hashimoto, the creator of Ghostty. He writes about creating a split-pane library with smooth animations. That means there can be no dropped frames and each animation frame has to make sense — no blank intermediate states or jumps.

00:53 We first focus on setting up the layout system, and we leave animation quality and profiling for the next step.

Split View and Panes

01:05 To get started, we define the core model types we need: a split view and a pane model. We model Pane as a struct with an id property to conform to Identifiable. We also add a random color to quickly give each pane a distinct look:

struct Pane: Identifiable {
    var id = UUID()
    var name: String
    var color = Color(hue: .random(in: 0...1), saturation: 0.8, brightness: 0.8)
}

02:32 We begin with horizontal splits only, which we can model as a simple array of panes as a state property in MySplitView. To render the panes in an HStack, we conform Pane to View by drawing a rounded rectangle with the pane's color and an overlay of the pane's name:

extension Pane: View {
    var body: some View {
        RoundedRectangle(cornerRadius: 8)
            .fill(color)
            .overlay {
                Text(name)
            }
    }
}

struct MySplitView: View {
    @State var splits: [Pane] = [Pane(name: "Pane 0")]

    var body: some View {
        HStack {
            ForEach(splits) { pane in
                pane
            }
        }
    }
}

03:47 We then embed MySplitView in ContentView, keeping the default padding so that we'll be able to see the edge behavior later on:

04:15 Next, we add a toolbar button to add panes to the right side of the view:

struct MySplitView: View {
    @State var splits: [Pane] = [Pane(name: "Pane 0")]

    var body: some View {
        HStack {
            ForEach(splits) { pane in
                pane
            }
        }
        .toolbar {
            Button("Right") {
                splits.append(Pane(name: "Pane \(splits.panes.count)"))
            }
        }
    }
}

Transitions

05:04 Now we can try adding some animation. By calling animation on the HStack, the insertions will get a default animation. Besides the frame changes, the newly added panes now also fade in — this is the default transition for views. By swapping this transition out for a .move, we can make the panes slide in from the trailing edge, which works well for right-side splits:

struct MySplitView: View {
    @State var splits: [Pane] = [Pane(name: "Pane 0")]

    var body: some View {
        HStack {
            ForEach(splits) { pane in
                pane
                    .transition(.move(edge: .trailing))
            }
        }
        .animation(.default, value: splits.panes.count)
        .toolbar {
            Button("Right") {
                splits.append(Pane(name: "Pane \(splits.panes.count)"))
            }
        }
    }
}

Splits Tree

05:51 In order to support both horizontal and vertical splits, we have to change the data model. Instead of an array, we should store a recursive tree of panes:

indirect enum Tree {
    case horizontal(Tree, Tree)
    case vertical(Tree, Tree)
    case pane(Pane)
}

06:28 The quickest way to make the view code compile again is adding a helper that extracts an array of panes from a tree:

indirect enum Tree {
    // ...
    var panes: [Pane] {
        switch self {
        case .horizontal(let tree, let tree2):
            tree.panes + tree2.panes
        case .vertical(let tree, let tree2):
            tree.panes + tree2.panes
        case .pane(let pane):
            [pane]
        }
    }
}

07:10 In MySplitView, we change the state property into a Tree, starting out with a single .pane node. In the body, we can now read the tree's panes array. And in the toolbar button's action, we can no longer append to an array, but instead we have to wrap the existing tree in a horizontal split with a new pane:

struct MySplitView: View {
    @State var splits: Tree = .pane(Pane(name: "Pane 0"))

    var body: some View {
        HStack {
            ForEach(splits.panes) { pane in
                pane
                    .transition(.move(edge: .trailing))
            }
        }
        .animation(.default, value: splits.panes.count)
        .toolbar {
            Button("Right") {
                let pane = Pane(name: "Pane \(splits.panes.count)")
                splits = .horizontal(splits, .pane(pane))
            }
        }
    }
}

Computing Rectangles from the Tree

07:52 The next step is getting rid of the HStack and replacing it with our own split layout. We write a layout method on Tree, in which we compute an array of pane-rectangle pairs.

08:27 We recursively step through the tree and switch over the current level. For .horizontal or .vertical cases, we divide the given rect along the appropriate axis and then we call layout on each subtree. For the .pane case, we return the current rect:

indirect enum Tree {
    // ...
    func layout(in rect: CGRect) -> [(Pane, CGRect)] {
        switch self {
        case .horizontal(let left, let right):
            let (leftRect, rightRect) = rect.divided(atDistance: rect.width/2, from: .minXEdge)
            return left.layout(in: leftRect) + right.layout(in: rightRect)

        case .vertical(let top, let bottom):
            let (topRect, bottomRect) = rect.divided(atDistance: rect.height/2, from: .minYEdge)
            return top.layout(in: topRect) + bottom.layout(in: bottomRect)

        case .pane(let pane):
            return [(pane, rect)]
        }
    }
}

Rendering with GeometryReader

10:14 We replace the HStack with a GeometryReader, iterate over the tuples returned by layout(in:), and place each pane with explicit frame and offset, so the rendering is driven by the computed rectangles:

struct MySplitView: View {
    @State var splits: Tree = .pane(Pane(name: "Pane 0"))

    var body: some View {
        GeometryReader { proxy in
            let rect = CGRect(origin: .zero, size: proxy.size)
            ForEach(splits.layout(in: rect), id: \.0.id) { (pane, rect) in
                pane
                    .frame(width: rect.width, height: rect.height)
                    .offset(x: rect.origin.x, y: rect.origin.y)
                    .transition(.move(edge: .trailing))
            }
        }
        // ...
    }
}

11:45 The layout method divides each nested split in two rectangles of equal widths. This is too simple for what we want in the end, but it's fine for now:

Adding Vertical Insertions and Next Steps

12:02 We add a second button to insert panes from the bottom by wrapping the existing tree in a vertical split:

struct MySplitView: View {
    @State var splits: Tree = .pane(Pane(name: "Pane 0"))

    var body: some View {
        GeometryReader { proxy in
            // ...
        }
        .animation(.default, value: splits.panes.count)
        .toolbar {
            Button("Right") {
                let pane = Pane(name: "Pane \(splits.panes.count)")
                splits = .horizontal(splits, .pane(pane))
            }
            Button("Bottom") {
                let pane = Pane(name: "Pane \(splits.panes.count)")
                splits = .vertical(splits, .pane(pane))
            }
        }
    }
}

12:18 Now we can have both horizontal and vertical splits. Of course, the transition of moving new panes in from the right side isn't correct for bottom panes, but we'll look at that later:

12:31 This is our basic setup. Next time, we can look at profiling and refining the animation behavior.

Resources

  • Sample Code

    Written in Swift 6.4

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

220 Episodes · 77h01min

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