Last active
November 2, 2025 21:32
-
-
Save Archetapp/3646f91dac0a404450d6a3e16d140a90 to your computer and use it in GitHub Desktop.
A split view similar to Abode. (Added keyboard avoidance logic.)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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