How to build a top/bottom split view with a dynamically-sized divider?

Is there a way to structure three views vertically with a top, middle divider, and bottom view, where the…

  • Middle divider view “hugs” its contents vertically (grows and shrinks based on height of child views)
  • Top and bottom views fill the space available above and below the divider
  • Divider can be dragged all the way up (or down), to completely hide the top view (or bottom view)

I’ve been working on this for a while and still can’t get it quite right. The code below is close, but the parent view’s bottom edge shifts when the divider resizes. As a result, the bottom view shifts upward when the divider shrinks, whereas I want it to continue to fill the space to the bottom of the screen.

import SwiftUI

struct ContentView: View {
    @State private var topRatio: CGFloat = 0.5
    @State private var dividerHeight: CGFloat = 44

    var body: some View {
        GeometryReader { geometry in
            let topInset = geometry.safeAreaInsets.top
            let bottomInset = geometry.safeAreaInsets.bottom
            let totalHeight = geometry.size.height
            let availableHeight = max(totalHeight - bottomInset - dividerHeight, 0)

            VStack(spacing: 0) {
                TopView()
                    .frame(height: max(availableHeight * topRatio - topInset, 0))
                    .frame(maxWidth: .infinity)
                    .background(Color.red.opacity(0.3))

                DividerView()
                    .background(GeometryReader { proxy in
                        Color.clear.preference(key: DividerHeightKey.self, value: proxy.size.height)
                    })
                    .onPreferenceChange(DividerHeightKey.self) { height in
                        dividerHeight = height
                    }
                    .gesture(
                        DragGesture()
                            .onChanged { value in
                                let maxDragDistance = availableHeight + dividerHeight
                                let translation = value.translation.height / max(maxDragDistance, 1)
                                let newTopRatio = topRatio + translation
                                topRatio = min(max(newTopRatio, 0), 1)
                            }
                    )
                    .zIndex(1)

                BottomView()
                    .frame(height: max(availableHeight * (1 - topRatio), 0))
                    .frame(maxWidth: .infinity)
                    .background(Color.green.opacity(0.3))
            }
        }
    }
}

struct DividerHeightKey: PreferenceKey {
    static var defaultValue: CGFloat = 44
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

struct DividerView: View {
    @State private var showExtraText = true

    var body: some View {
        VStack(spacing: 0) {
            Text(showExtraText ? "Tap to hide 'More'" : "Tap to show 'More'")
                .frame(height: 44)
            if showExtraText {
                Text("More")
                    .frame(height: 44)
            }
        }
        .frame(maxWidth: .infinity)
        .background(Color.gray)
        .onTapGesture {
                showExtraText.toggle()
        }
    }
}

struct TopView: View {
    var body: some View {
        VStack {
            Spacer()
            Text("Top")
        }
        .padding(0)
    }
}

struct BottomView: View {
    var body: some View {
        VStack {
            Text("Bottom")
            Spacer()
        }
        .padding(0)
    }
}

#Preview {
    ContentView()
}
Answered by Bikrrr in 811551022

I finally figured it out—this code achieves the behavior I was looking for:

import SwiftUI

enum SnapPoint: CGFloat, CaseIterable {
    case top = 0.0
    case partial = 0.33
    case bottom = 1.0

    var value: CGFloat {
        return self.rawValue
    }

    static let allValues: [CGFloat] = SnapPoint.allCases.map { $0.rawValue }
}

struct ContentView: View {
    @State private var totalHeight: CGFloat = 0
    @State private var topHeight: CGFloat = 0
    @State private var dividerHeight: CGFloat = 0
    @State private var showMore: Bool = true
    @State private var currentSnapPoint: SnapPoint = .partial
    @State private var previousDividerHeight: CGFloat = 0

    let snapPoints: [SnapPoint] = SnapPoint.allCases

    var body: some View {
        GeometryReader { geometry in
            VStack(spacing: 0) {
                TopView()
                    .frame(maxWidth: .infinity)
                    .frame(height: max(0, topHeight))
                    .background(Color.red.opacity(0.3))
                    .border(.pink)
                    .clipped()

                DividerView(showMore: $showMore)
                    .zIndex(1)
                    .background(
                        GeometryReader { dividerGeometry in
                            Color.clear
                                .onAppear {
                                    dividerHeight = dividerGeometry.size.height
                                    if totalHeight == 0 {
                                        totalHeight = geometry.size.height
                                        topHeight = calculate(.partial)
                                    }
                                    previousDividerHeight = dividerHeight
                                }
                                .onChange(of: dividerGeometry.size.height) {
                                    withAnimation(.snappy(duration: 0.2)) {
                                        let deltaHeight = dividerGeometry.size.height - previousDividerHeight
                                        previousDividerHeight = dividerGeometry.size.height
                                        dividerHeight = dividerGeometry.size.height
                                        if currentSnapPoint != .top {
                                            topHeight = max(0, topHeight - deltaHeight)
                                        }
                                        if totalHeight == 0 {
                                            totalHeight = geometry.size.height
                                            topHeight = (totalHeight - dividerHeight) / 2
                                        }
                                    }
                                }

                        }
                    )
                    .gesture(
                        DragGesture()
                            .onChanged { value in
                                topHeight = calculateDraggedTopHeight(value.translation.height)
                            }
                            .onEnded { _ in
                                withAnimation(.snappy(duration: 0.2)) {
                                    let (snapPoint, height) = nearestSnapPoint(for: topHeight)
                                    topHeight = height
                                    currentSnapPoint = snapPoint
                                }
                            }
                    )

                BottomView()
                    .frame(maxWidth: .infinity)
                    .frame(height: max(0, geometry.size.height - topHeight - dividerHeight))
                    .background(Color.green.opacity(0.3))
                    .border(.pink)
                    .clipped()
            }
            .onChange(of: geometry.size.height) {
                totalHeight = geometry.size.height
                topHeight = min(topHeight, totalHeight - dividerHeight)
            }
        }
    }

    func calculateDraggedTopHeight(_ translation: CGFloat) -> CGFloat {
        return max(0, min(topHeight + translation, totalHeight - dividerHeight))
    }

    func nearestSnapPoint(for height: CGFloat) -> (SnapPoint, CGFloat) {
        let calculatedPoints = snapPoints.map { ($0, calculate($0)) }
        let nearest = calculatedPoints.min(by: { abs($0.1 - height) < abs($1.1 - height) }) ?? (.partial, height)
        return nearest
    }

    func calculate(_ point: SnapPoint) -> CGFloat {
        switch point {
        case .top:
            return 0
        case .partial:
            return (totalHeight * point.value) - dividerHeight
        case .bottom:
            return totalHeight - dividerHeight
        }
    }
}

struct DividerView: View {
    @Binding var showMore: Bool

    var body: some View {
        VStack(spacing: 0) {
            Text(showMore ? "Tap to hide 'More'" : "Tap to show 'More'")
                .padding(16)
                .multilineTextAlignment(.center)
            if showMore {
                Text("More")
                    .padding(16)
            }
        }
        .frame(maxWidth: .infinity)
        .background(Color(.systemBackground))
        .onTapGesture {
            withAnimation(.snappy(duration: 0.2)) {
                showMore.toggle()
            }
        }
    }
}

struct TopView: View {
    var body: some View {
        Text("Top")
    }
}

struct BottomView: View {
    var body: some View {
        Text("Bottom")
    }
}

#Preview {
    ContentView()
}

Here's an animated GIF of the issue:

Accepted Answer

I finally figured it out—this code achieves the behavior I was looking for:

import SwiftUI

enum SnapPoint: CGFloat, CaseIterable {
    case top = 0.0
    case partial = 0.33
    case bottom = 1.0

    var value: CGFloat {
        return self.rawValue
    }

    static let allValues: [CGFloat] = SnapPoint.allCases.map { $0.rawValue }
}

struct ContentView: View {
    @State private var totalHeight: CGFloat = 0
    @State private var topHeight: CGFloat = 0
    @State private var dividerHeight: CGFloat = 0
    @State private var showMore: Bool = true
    @State private var currentSnapPoint: SnapPoint = .partial
    @State private var previousDividerHeight: CGFloat = 0

    let snapPoints: [SnapPoint] = SnapPoint.allCases

    var body: some View {
        GeometryReader { geometry in
            VStack(spacing: 0) {
                TopView()
                    .frame(maxWidth: .infinity)
                    .frame(height: max(0, topHeight))
                    .background(Color.red.opacity(0.3))
                    .border(.pink)
                    .clipped()

                DividerView(showMore: $showMore)
                    .zIndex(1)
                    .background(
                        GeometryReader { dividerGeometry in
                            Color.clear
                                .onAppear {
                                    dividerHeight = dividerGeometry.size.height
                                    if totalHeight == 0 {
                                        totalHeight = geometry.size.height
                                        topHeight = calculate(.partial)
                                    }
                                    previousDividerHeight = dividerHeight
                                }
                                .onChange(of: dividerGeometry.size.height) {
                                    withAnimation(.snappy(duration: 0.2)) {
                                        let deltaHeight = dividerGeometry.size.height - previousDividerHeight
                                        previousDividerHeight = dividerGeometry.size.height
                                        dividerHeight = dividerGeometry.size.height
                                        if currentSnapPoint != .top {
                                            topHeight = max(0, topHeight - deltaHeight)
                                        }
                                        if totalHeight == 0 {
                                            totalHeight = geometry.size.height
                                            topHeight = (totalHeight - dividerHeight) / 2
                                        }
                                    }
                                }

                        }
                    )
                    .gesture(
                        DragGesture()
                            .onChanged { value in
                                topHeight = calculateDraggedTopHeight(value.translation.height)
                            }
                            .onEnded { _ in
                                withAnimation(.snappy(duration: 0.2)) {
                                    let (snapPoint, height) = nearestSnapPoint(for: topHeight)
                                    topHeight = height
                                    currentSnapPoint = snapPoint
                                }
                            }
                    )

                BottomView()
                    .frame(maxWidth: .infinity)
                    .frame(height: max(0, geometry.size.height - topHeight - dividerHeight))
                    .background(Color.green.opacity(0.3))
                    .border(.pink)
                    .clipped()
            }
            .onChange(of: geometry.size.height) {
                totalHeight = geometry.size.height
                topHeight = min(topHeight, totalHeight - dividerHeight)
            }
        }
    }

    func calculateDraggedTopHeight(_ translation: CGFloat) -> CGFloat {
        return max(0, min(topHeight + translation, totalHeight - dividerHeight))
    }

    func nearestSnapPoint(for height: CGFloat) -> (SnapPoint, CGFloat) {
        let calculatedPoints = snapPoints.map { ($0, calculate($0)) }
        let nearest = calculatedPoints.min(by: { abs($0.1 - height) < abs($1.1 - height) }) ?? (.partial, height)
        return nearest
    }

    func calculate(_ point: SnapPoint) -> CGFloat {
        switch point {
        case .top:
            return 0
        case .partial:
            return (totalHeight * point.value) - dividerHeight
        case .bottom:
            return totalHeight - dividerHeight
        }
    }
}

struct DividerView: View {
    @Binding var showMore: Bool

    var body: some View {
        VStack(spacing: 0) {
            Text(showMore ? "Tap to hide 'More'" : "Tap to show 'More'")
                .padding(16)
                .multilineTextAlignment(.center)
            if showMore {
                Text("More")
                    .padding(16)
            }
        }
        .frame(maxWidth: .infinity)
        .background(Color(.systemBackground))
        .onTapGesture {
            withAnimation(.snappy(duration: 0.2)) {
                showMore.toggle()
            }
        }
    }
}

struct TopView: View {
    var body: some View {
        Text("Top")
    }
}

struct BottomView: View {
    var body: some View {
        Text("Bottom")
    }
}

#Preview {
    ContentView()
}
How to build a top/bottom split view with a dynamically-sized divider?
 
 
Q