Skip to content

Instantly share code, notes, and snippets.

@Archetapp
Last active November 2, 2025 21:32
Show Gist options
  • Select an option

  • Save Archetapp/3646f91dac0a404450d6a3e16d140a90 to your computer and use it in GitHub Desktop.

Select an option

Save Archetapp/3646f91dac0a404450d6a3e16d140a90 to your computer and use it in GitHub Desktop.
A split view similar to Abode. (Added keyboard avoidance logic.)
import SwiftUI
// MARK: - Component
fileprivate struct SplitViewConfig {
let topDetentRatio: CGFloat
let bottomDetentRatio: CGFloat
let elasticThreshold: CGFloat
let elasticResistance: CGFloat
let snapThreshold: CGFloat
let dividerHeight: CGFloat
let cornerRadius: CGFloat
let animationDuration: CGFloat
let velocityMultiplier: CGFloat
let backgroundColor: Color
static let `default` = SplitViewConfig(
topDetentRatio: 0.80,
bottomDetentRatio: 0.20,
elasticThreshold: 50,
elasticResistance: 0.2,
snapThreshold: 100,
dividerHeight: 30,
cornerRadius: 30,
animationDuration: 0.35,
velocityMultiplier: 0.1,
backgroundColor: .red
)
}
public enum SplitViewDetent {
case top
case bottom
}
public struct SplitView<Top: View, Bottom: View>: View {
@Binding public var currentDetent: SplitViewDetent
public let top: () -> Top
public let bottom: () -> Bottom
@State private var dragOffset: CGFloat = 0
@State private var isDragging = false
@State private var isKeyboardVisible = false
private let config: SplitViewConfig
public init(
currentDetent: Binding<SplitViewDetent>,
@ViewBuilder top: @escaping () -> Top,
@ViewBuilder bottom: @escaping () -> Bottom
) {
self._currentDetent = currentDetent
self.top = top
self.bottom = bottom
self.config = .default
}
public var body: some View {
GeometryReader { geometry in
let detentHeight = currentDetent == .top
? geometry.size.height * config.topDetentRatio
: geometry.size.height * config.bottomDetentRatio
let elasticOffset = calculateElasticOffset()
let dividerY = detentHeight + elasticOffset
let bottomHeight = max(0, geometry.size.height - (dividerY + config.dividerHeight))
ZStack(alignment: .top) {
config.backgroundColor
.ignoresSafeArea(.container)
topSection(width: geometry.size.width, height: dividerY)
bottomSection(width: geometry.size.width, height: bottomHeight)
divider(width: geometry.size.width, yOffset: dividerY)
}
}
.onAppear {
setupKeyboardObservers()
}
.onDisappear {
removeKeyboardObservers()
}
.onChange(of: isKeyboardVisible) { _, newValue in
if newValue && currentDetent != .bottom {
withAnimation(.spring(duration: config.animationDuration, bounce: 0)) {
currentDetent = .bottom
}
}
}
}
private func calculateElasticOffset() -> CGFloat {
if currentDetent == .top {
if dragOffset > config.elasticThreshold {
let excess = dragOffset - config.elasticThreshold
return config.elasticThreshold + (excess * config.elasticResistance)
}
} else {
if dragOffset < -config.elasticThreshold {
let excess = abs(dragOffset) - config.elasticThreshold
return -(config.elasticThreshold + (excess * config.elasticResistance))
}
}
return dragOffset
}
private func topSection(width: CGFloat, height: CGFloat) -> some View {
top()
.frame(width: width, height: height)
.background(Color(.systemBackground))
.clipShape(RoundedCorner(radius: config.cornerRadius, corners: [.bottomLeft, .bottomRight]))
}
private func bottomSection(width: CGFloat, height: CGFloat) -> some View {
VStack {
Spacer()
bottom()
.frame(width: width, height: height)
.clipped()
.clipShape(RoundedCorner(radius: config.cornerRadius, corners: [.topLeft, .topRight]))
}
}
private func divider(width: CGFloat, yOffset: CGFloat) -> some View {
ZStack {
config.backgroundColor.opacity(0.00001) // Allows larger touch area
.frame(width: width, height: config.dividerHeight)
.contentShape(Rectangle())
SplitDivider(isDragging: isDragging)
}
.offset(y: yOffset)
.gesture(dragGesture)
}
private var dragGesture: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { value in
if !isDragging {
isDragging = true
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
}
dragOffset = value.translation.height
}
.onEnded { value in
handleDragEnd(value: value)
}
}
private func handleDragEnd(value: DragGesture.Value) {
let velocity = value.predictedEndTranslation.height - value.translation.height
let projectedOffset = dragOffset + velocity * config.velocityMultiplier
withAnimation(.spring(duration: config.animationDuration, bounce: 0)) {
if shouldSwitchDetent(projectedOffset: projectedOffset) {
currentDetent = currentDetent == .top ? .bottom : .top
}
dragOffset = 0
isDragging = false
}
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
private func shouldSwitchDetent(projectedOffset: CGFloat) -> Bool {
if currentDetent == .top {
return projectedOffset < -config.snapThreshold
} else {
return projectedOffset > config.snapThreshold
}
}
private func setupKeyboardObservers() {
NotificationCenter.default.addObserver(
forName: UIResponder.keyboardWillShowNotification,
object: nil,
queue: .main
) { _ in
isKeyboardVisible = true
}
NotificationCenter.default.addObserver(
forName: UIResponder.keyboardWillHideNotification,
object: nil,
queue: .main
) { _ in
isKeyboardVisible = false
}
}
private func removeKeyboardObservers() {
NotificationCenter.default.removeObserver(
self,
name: UIResponder.keyboardWillShowNotification,
object: nil
)
NotificationCenter.default.removeObserver(
self,
name: UIResponder.keyboardWillHideNotification,
object: nil
)
}
}
private struct SplitDivider: View {
let isDragging: Bool
var body: some View {
HStack(spacing: 6) {
Capsule()
.fill(.white.opacity(0.5))
.frame(width: 40, height: 5)
}
.frame(height: 30)
.frame(maxWidth: .infinity)
}
}
private struct RoundedCorner: Shape {
var radius: CGFloat = .infinity
var corners: UIRectCorner = .allCorners
func path(in rect: CGRect) -> Path {
let path = UIBezierPath(
roundedRect: rect,
byRoundingCorners: corners,
cornerRadii: CGSize(width: radius, height: radius)
)
return Path(path.cgPath)
}
}
// MARK: - Example
struct iMessageSplitViewShowcase: View, ShowcaseExample {
static var showcaseName: String { "iMessage Split View" }
static var showcaseCategory: String { "Split View" }
@State private var currentDetent: SplitViewDetent = .top
var body: some View {
SplitView(
currentDetent: $currentDetent,
top: {
MediaGridView()
},
bottom: {
MessageThreadView()
}
)
.ignoresSafeArea(.container)
}
}
private struct MediaGridView: View {
private let photos = [
("photo.fill", Color.blue),
("photo.fill", Color.purple),
("photo.fill", Color.pink),
("photo.fill", Color.orange),
("photo.fill", Color.green),
("photo.fill", Color.red),
("photo.fill", Color.blue),
("photo.fill", Color.purple),
("photo.fill", Color.pink),
("photo.fill", Color.orange)
]
var body: some View {
VStack(spacing: 0) {
ScrollView {
LazyVGrid(columns: [SwiftUI.GridItem(.flexible()), SwiftUI.GridItem(.flexible())], spacing: 12) {
ForEach(0..<photos.count, id: \.self) { index in
ZStack {
RoundedRectangle(cornerRadius: 16)
.fill(photos[index].1.opacity(0.3))
Image(systemName: photos[index].0)
.font(.system(size: 40))
.foregroundStyle(photos[index].1)
if index == 1 {
VStack {
HStack {
Spacer()
Text("5 new")
.font(.caption.bold())
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Capsule().fill(.blue))
.padding(8)
}
Spacer()
}
}
}
.frame(height: 150)
}
}
.padding()
.padding(.top, 80)
}
}
.background(Color(.systemBackground))
.safeAreaPadding(.top)
}
}
private struct MessageThreadView: View {
private let messages: [(text: String, isUser: Bool, isSystem: Bool)] = [
("Joe hit a new personal best of 2037 on Ascend", false, true),
("Joe added Album", false, true),
("Joe added 2 items to We love dogs", false, true),
("Bro wtf", true, false),
("Joe updated the abode theme", false, true),
("Why is this so good", true, false),
("😭😭😭", true, false),
("πŸ˜‚πŸ˜‚", false, false),
("Omg so cute", false, false)
]
@State private var messageText = ""
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(spacing: 8) {
ForEach(0..<messages.count, id: \.self) { index in
MessageBubble(
text: messages[index].text,
isUser: messages[index].isUser,
isSystem: messages[index].isSystem
)
}
}
.padding()
}
HStack(spacing: 12) {
Button {} label: {
Image(systemName: "camera.fill")
.font(.title3)
.foregroundStyle(.primary)
}
Button {} label: {
Text("GIF")
.font(.subheadline.bold())
.foregroundStyle(.primary)
}
TextField("Message", text: $messageText)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
Capsule()
.strokeBorder(Color(.systemGray4), lineWidth: 1)
)
Button {} label: {
Image(systemName: "face.smiling")
.font(.title3)
.foregroundStyle(.primary)
}
Button {} label: {
Image(systemName: "clock.arrow.circlepath")
.font(.title3)
.foregroundStyle(.primary)
}
}
.padding()
.safeAreaPadding(.bottom)
.background(Color(.systemBackground))
}
.background(Color(.systemBackground))
}
}
private struct MessageBubble: View {
let text: String
let isUser: Bool
let isSystem: Bool
var body: some View {
HStack {
if isUser { Spacer() }
if isSystem {
Text(attributedText)
.font(.subheadline)
.multilineTextAlignment(.center)
.padding()
} else {
Text(text)
.font(.body)
.foregroundStyle(isUser ? .white : .primary)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(
Capsule()
.fill(isUser ? Color.blue : Color(.systemGray5))
)
}
if !isUser { Spacer() }
}
}
private var attributedText: AttributedString {
var result = AttributedString(text)
if let range = result.range(of: "Joe") {
result[range].foregroundColor = .blue
result[range].font = .subheadline.bold()
}
if let range = result.range(of: "Raffi") {
result[range].foregroundColor = .blue
result[range].font = .subheadline.bold()
}
if let range = result.range(of: "We love dogs") {
result[range].foregroundColor = .blue
result[range].font = .subheadline.bold()
}
if let range = result.range(of: "Ascend") {
result[range].foregroundColor = .blue
result[range].font = .subheadline.bold()
}
if let range = result.range(of: "Album") {
result[range].foregroundColor = .blue
result[range].font = .subheadline.bold()
}
return result
}
}
#Preview {
iMessageSplitViewShowcase()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment