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()
}
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()
}