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