├── .gitignore ├── Examples ├── MapExample.swift └── PhotoEditExample.swift ├── Package.swift ├── README.md └── Sources └── VerticalSplit ├── Accessories.swift ├── Helpers.swift ├── Modifiers.swift ├── SplitDetent.swift ├── VerticalSplit.swift └── Wrappers.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Examples/MapExample.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MapsExample.swift 3 | // VerticalSplit 4 | // 5 | // Created by Vedant Gurav on 27/02/2024. 6 | // 7 | 8 | import SwiftUI 9 | import VerticalSplit 10 | 11 | struct MapsExample: View { 12 | @State var detent = SplitDetent.bottomMini 13 | 14 | var body: some View { 15 | VerticalSplit( 16 | detent: $detent, 17 | topTitle: "Map", 18 | bottomTitle: "Directions", 19 | topView: { 20 | ZStack { 21 | Image("map") 22 | .resizable() 23 | .aspectRatio(contentMode: .fill) 24 | .frame(maxWidth: 393, maxHeight: .infinity, alignment: .center) 25 | } 26 | .frame(maxHeight: .infinity, alignment: .center) 27 | .ignoresSafeArea(.all) 28 | }, 29 | bottomView: { 30 | ScrollView { 31 | VStack(alignment: .leading, spacing: /*@START_MENU_TOKEN@*/nil/*@END_MENU_TOKEN@*/) { 32 | Text("Directions") 33 | .font(.title.bold()) 34 | .padding(.top) 35 | Image("directions") 36 | .resizable() 37 | .scaledToFill() 38 | .frame(maxWidth: .infinity, alignment: .center) 39 | .clipShape(.rect(cornerRadius: 8)) 40 | } 41 | .padding(16) 42 | } 43 | }, 44 | topMiniOverlay: { 45 | Text("7 stops · 10 min walk · in 12 min") 46 | .font(.system(size: 18, design: .rounded)) 47 | .fontWeight(.medium) 48 | .frame(maxWidth: .infinity) 49 | }, 50 | bottomMiniOverlay: { 51 | Text("Walk to South Kensington Museums stop") 52 | .font(.system(size: 18, design: .rounded)) 53 | .fontWeight(.medium) 54 | .frame(maxWidth: .infinity) 55 | } 56 | ) 57 | .leadingAccessories([ 58 | .init(systemName: "figure.walk", color: .gray, action: {}), 59 | .init(systemName: "car", color: .gray, action: {}), 60 | .init(systemName: "tram", action: {}) 61 | ]) 62 | .trailingAccessories([ 63 | .init(systemName: "mountain.2.fill", action: {}), 64 | .init(systemName: "arrow.up.circle.fill", action: {}) 65 | ]) 66 | } 67 | } 68 | 69 | #Preview { 70 | MapsExample() 71 | } 72 | -------------------------------------------------------------------------------- /Examples/PhotoEditExample.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoEditExample.swift 3 | // VerticalSplit 4 | // 5 | // Created by Vedant Gurav on 26/02/2024. 6 | // 7 | 8 | import SwiftUI 9 | import VerticalSplit 10 | 11 | struct PhotoEditExample: View { 12 | @State var saturation: CGFloat = 0.5 13 | @State var contrast: CGFloat = 0.5 14 | @State var shadows: CGFloat = 0.5 15 | @State var rotate: CGFloat = 0.5 16 | 17 | @State var detent: SplitDetent = .topFull 18 | 19 | @State var isAuto = false 20 | @State var isFilled = false 21 | 22 | var body: some View { 23 | VerticalSplit( 24 | detent: $detent, 25 | topTitle: "Photo", 26 | bottomTitle: "Controls", 27 | topView: { 28 | ZStack { 29 | Image("neist") 30 | .resizable() 31 | .aspectRatio(contentMode: isFilled ? .fill : .fit) 32 | .saturation(saturation * 2.0) 33 | .contrast(contrast * 2.0) 34 | .brightness((shadows * 2.0 - 1.0) / 10.0) 35 | .frame(maxWidth: 393, maxHeight: .infinity, alignment: .center) 36 | .rotationEffect(.degrees(rotate * 10.0 - 5.0)) 37 | .scaleEffect(1 + abs(rotate - 0.5)) 38 | } 39 | .frame(maxHeight: .infinity) 40 | .ignoresSafeArea(edges: .all) 41 | }, 42 | bottomView: { 43 | ScrollView { 44 | VStack(spacing: 20) { 45 | CustomSlider(title: "Saturation", value: $saturation) { 46 | String(format: "%.1f", $0 * 2 - 1) 47 | } 48 | 49 | CustomSlider(title: "Contrast", value: $contrast) { 50 | String(format: "%.1f", $0 * 2 - 1) 51 | } 52 | 53 | CustomSlider(title: "Shadows", value: $shadows) { 54 | String(format: "%.1f", $0 * 2 - 1) 55 | } 56 | 57 | CustomSlider(title: "Straighten", value: $rotate) { 58 | String(format: "%.1f", $0 * 10 - 5) 59 | } 60 | } 61 | .padding() 62 | .padding(.top) 63 | } 64 | }, 65 | topMiniOverlay: { 66 | HStack { 67 | Image(systemName: "photo.fill") 68 | Text("Photo") 69 | .fontWeight(.medium) 70 | } 71 | .font(.system(size: 20)) 72 | .frame(maxWidth: .infinity) 73 | }, 74 | bottomMiniOverlay: { 75 | HStack { 76 | Image(systemName: "dial.medium.fill") 77 | Text("Controls") 78 | .fontWeight(.medium) 79 | } 80 | .font(.system(size: 20)) 81 | .frame(maxWidth: .infinity) 82 | } 83 | ) 84 | .leadingAccessories([ 85 | .init(systemName: "arrow.uturn.backward.circle.fill", action: {}), 86 | .init(systemName: "arrow.uturn.forward.circle.fill", action: {}) 87 | ]) 88 | .trailingAccessories([ 89 | .init(systemName: "arrow.counterclockwise.circle.fill", action: { 90 | withAnimation(.smooth(duration: 0.4)) { 91 | saturation = 0.5 92 | contrast = 0.5 93 | shadows = 0.5 94 | rotate = 0.5 95 | isAuto = false 96 | } 97 | }) 98 | ]) 99 | .menuAccessories([ 100 | .init(title: "Markup", systemName: "pencil.tip.crop.circle") { 101 | detent = .topFull 102 | }, 103 | .init( 104 | title: isFilled ? "Fit" : "Fill", 105 | systemName: isFilled ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right" 106 | ) { 107 | withAnimation(.smooth(duration: 0.6)) { 108 | isFilled.toggle() 109 | } 110 | }, 111 | .init( 112 | title: isAuto ? "Manual" : "Auto", 113 | systemName: "wand.and.stars", 114 | color: isAuto ? .yellow : .white 115 | ) { 116 | withAnimation(.smooth(duration: 0.4)) { 117 | if isAuto { 118 | saturation = 0.5 119 | contrast = 0.5 120 | shadows = 0.5 121 | rotate = 0.5 122 | } else { 123 | saturation = 0.65 124 | contrast = 0.55 125 | shadows = 0.8 126 | rotate = 0.46 127 | } 128 | isAuto.toggle() 129 | } 130 | }, 131 | ]) 132 | .debug(true) 133 | .backgroundColor(.init(uiColor: UIColor(white: 0.16, alpha: 1))) 134 | } 135 | } 136 | 137 | struct CustomSlider: View { 138 | let title: String 139 | @Binding var value: CGFloat 140 | let makeLabel: (CGFloat) -> String 141 | @GestureState var initialValue: CGFloat? 142 | @State var width: CGFloat = 0 143 | let size: CGFloat = 32 144 | var body: some View { 145 | VStack(alignment: .leading, spacing: 6) { 146 | HStack { 147 | Text(title) 148 | Spacer() 149 | Text(makeLabel(value)) 150 | .contentTransition(.numericText(value: value)) 151 | .monospacedDigit() 152 | } 153 | .font(.system(size: 18, weight: .medium, design: .rounded)) 154 | .padding(.horizontal, size / 2) 155 | ZStack(alignment: .leading) { 156 | Color.primary.opacity(0.1) 157 | Color.primary.opacity(0.2) 158 | .clipShape(.capsule) 159 | .overlay(alignment: .trailing) { 160 | Circle() 161 | .fill(.white) 162 | .shadow(color: .black.opacity(0.2), radius: 8) 163 | .padding(4) 164 | } 165 | .frame(width: size + (width - size) * value) 166 | } 167 | .clipShape(.capsule) 168 | .background { 169 | GeometryReader(content: { geometry in 170 | Color.clear.onAppear { 171 | width = geometry.size.width 172 | } 173 | }) 174 | } 175 | .gesture( 176 | DragGesture(coordinateSpace: .global) 177 | .updating($initialValue, body: { v, s, _ in 178 | if s == nil { 179 | s = value 180 | } 181 | }) 182 | .onChanged({ v in 183 | withAnimation(.smooth(duration: 0.2)) { 184 | value = max(0, min(1, (initialValue ?? 0) + v.translation.width / (width - size))) 185 | } 186 | }) 187 | ) 188 | .frame(height: size) 189 | } 190 | .frame(maxWidth: .infinity) 191 | } 192 | } 193 | 194 | #Preview { 195 | PhotoEditExample() 196 | } 197 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "VerticalSplit", 8 | platforms: [.iOS(.v17)], 9 | products: [ 10 | // Products define the executables and libraries a package produces, making them visible to other packages. 11 | .library( 12 | name: "VerticalSplit", 13 | targets: ["VerticalSplit"]), 14 | ], 15 | targets: [ 16 | // Targets are the basic building blocks of a package, defining a module or a test suite. 17 | // Targets can depend on other targets in this package and products from dependencies. 18 | .target( 19 | name: "VerticalSplit") 20 | ] 21 | ) 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VerticalSplit 2 | This package is ***heavily*** inspired by the splitscreen implementation from the [Amie](https://www.amie.so) iOS app. This is my attempt to recreate it 3 | 4 | ## Examples 5 | 6 | 7 | 8 | https://github.com/vedantgurav/VerticalSplit/assets/40576802/003911eb-4cd2-4a56-aba2-b0b51cba07bc 9 | 10 | 11 | https://github.com/vedantgurav/VerticalSplit/assets/40576802/f2b41558-d8d1-4804-b9a3-b3c201f759a1 12 | 13 | 14 | 15 | 16 | ## Usage 17 | 18 | ```swift 19 | import VerticalSplit 20 | 21 | VerticalSplit( 22 | topTitle: "Top View", 23 | bottomTitle: "Bottom View", 24 | topView: { 25 | // Top Content 26 | }, 27 | bottomView: { 28 | // Bottom Content 29 | } 30 | ) 31 | ``` 32 | 33 | ### Mini Overlays 34 | 35 | Add custom content to show when the top or bottom views are in their smallest size. 36 | 37 | ```swift 38 | VerticalSplit( 39 | topTitle: "Top View", 40 | bottomTitle: "Bottom View", 41 | topView: { 42 | // Top Content 43 | }, 44 | bottomView: { 45 | // Bottom Content 46 | }, 47 | topMiniOverlay: { 48 | // Shown instead of the Top Content when Top View is minimised 49 | }, 50 | bottomMiniOverlay: { 51 | // Shown instead of the Bottom Content when Bottom View is minimised 52 | } 53 | ) 54 | ``` 55 | 56 | ### SplitDetent Binding 57 | 58 | Use a binding to control the split between the top and bottom views. 59 | 60 | ```swift 61 | @State var currentDetent: SplitDetent.fraction(0.5) 62 | 63 | VerticalSplit( 64 | detent: $currentDetent 65 | topTitle: "Top View", 66 | bottomTitle: "Bottom View", 67 | ... 68 | ) 69 | ``` 70 | 71 | ### Accessories 72 | 73 | Use the `leadingAccessories` and `trailingAccessories` modifiers to add buttons in the drag region. 74 | 75 | ```swift 76 | VerticalSplit(...) 77 | .leadingAccessories([ 78 | SplitAccessory(systemName: "plus.circle.fill") { 79 | // Perform action 80 | }, 81 | SplitAccessory(systemName: "minus.circle.fill") { 82 | // Perform action 83 | } 84 | ]) 85 | ``` 86 | 87 | ### Menu Accessories 88 | 89 | Use the `menuAccessories` modifier to add buttons in a pop-out menu un the drag region. 90 | 91 | ```swift 92 | VerticalSplit(...) 93 | .menuAccessories([ 94 | MenuAccessory(title: "Plus", systemName: "plus.circle.fill", color: .green) { 95 | // Perform action 96 | }, 97 | MenuAccessory(title: "Minus", systemName: "minus.circle.fill", color: .red) { 98 | // Perform action 99 | } 100 | ]) 101 | ``` 102 | 103 | ### Background Color 104 | 105 | Set the background color for the top and bottom view containers, as well as the menu buttons, 106 | 107 | ```swift 108 | VerticalSplit(...) 109 | .backgroundColor(.gray) 110 | ``` 111 | 112 | ### Debugging 113 | 114 | Control whether or not logs are made for debugging. 115 | 116 | ```swift 117 | VerticalSplit(...) 118 | .debug(true) 119 | ``` 120 | -------------------------------------------------------------------------------- /Sources/VerticalSplit/Accessories.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Accessories.swift 3 | // VerticalSplit 4 | // 5 | // Created by Vedant Gurav on 28/02/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// Accessory buttons shown on each side of the drag indicator in the VerticalSplit. 11 | public struct SplitAccessory: Identifiable { 12 | public var id: String { title + systemName } 13 | let title: String 14 | let systemName: String 15 | let color: Color 16 | let action: () -> Void 17 | 18 | /// Creates an accessory with the associated action. 19 | /// - Parameters: 20 | /// - title: Name of the accessory. 21 | /// - systemName: SFSymbol for the label of the button. 22 | /// - color: Foreground color applied to the label of the button. 23 | /// - action: Action to be performed when accessory is tapped. 24 | public init(title: String? = nil, systemName: String, color: Color = .white, action: @escaping () -> Void) { 25 | self.systemName = systemName 26 | self.action = action 27 | self.color = color 28 | self.title = title ?? systemName 29 | } 30 | } 31 | 32 | /// Larger accessory buttons shown in a pop-out menu in the VerticalSplit. 33 | public struct MenuAccessory: Identifiable { 34 | public var id: String { title + systemName } 35 | let title: String 36 | let systemName: String 37 | let color: Color 38 | let action: () -> Void 39 | 40 | /// Creates an accessory with the associated action. 41 | /// - Parameters: 42 | /// - title: Name of the accessory, shown beside the symbol. 43 | /// - systemName: SFSymbol for the label of the button. 44 | /// - color: Foreground color applied to the label of the button. 45 | /// - action: Action to be performed when accessory is tapped. 46 | public init(title: String? = nil, systemName: String, color: Color = Color(uiColor: .label), action: @escaping () -> Void) { 47 | self.systemName = systemName 48 | self.action = action 49 | self.color = color 50 | self.title = title ?? systemName 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/VerticalSplit/Helpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Helpers.swift 3 | // VerticalSplit 4 | // 5 | // Created by Vedant Gurav on 26/02/2024. 6 | // 7 | 8 | import UIKit 9 | import SwiftUI 10 | 11 | struct ScaleDownButtonStyle: ButtonStyle { 12 | func makeBody(configuration: Configuration) -> some View { 13 | configuration.label 14 | .opacity(configuration.isPressed ? 0.8 : 1) 15 | .scaleEffect(configuration.isPressed ? 0.85 : 1) 16 | .animation(.smooth(duration: configuration.isPressed ? 0.1 : 0.2), value: configuration.isPressed) 17 | } 18 | } 19 | 20 | extension UIScreen { 21 | private static let cornerRadiusKey: String = { 22 | let components = ["Radius", "Corner", "display", "_"] 23 | return components.reversed().joined() 24 | }() 25 | 26 | var displayCornerRadius: CGFloat { 27 | guard let cornerRadius = self.value(forKey: Self.cornerRadiusKey) as? CGFloat else { 28 | return 0 29 | } 30 | 31 | return cornerRadius 32 | } 33 | } 34 | 35 | 36 | struct BlurTransitionModifier: ViewModifier { 37 | let radius: CGFloat 38 | 39 | func body(content: Content) -> some View { 40 | content.blur(radius: radius) 41 | } 42 | } 43 | 44 | public extension AnyTransition { 45 | static func blur(radius: CGFloat = 4) -> AnyTransition { 46 | .modifier(active: BlurTransitionModifier(radius: radius), identity: .init(radius: 0)) 47 | } 48 | } 49 | 50 | 51 | struct SafeAreaInsetsKey: EnvironmentKey { 52 | static var defaultValue: EdgeInsets { 53 | UIApplication.shared.safeAreaInsets.insets 54 | } 55 | } 56 | 57 | extension EdgeInsets { 58 | var smartBottom: CGFloat { 59 | bottom == 0 ? 16 : bottom 60 | } 61 | 62 | var vertical: CGFloat { 63 | top + bottom 64 | } 65 | 66 | var horizontal: CGFloat { 67 | leading + trailing 68 | } 69 | } 70 | 71 | extension EnvironmentValues { 72 | var safeAreaInsets: EdgeInsets { 73 | self[SafeAreaInsetsKey.self] 74 | } 75 | } 76 | 77 | extension UIEdgeInsets { 78 | var insets: EdgeInsets { 79 | EdgeInsets(top: top, leading: left, bottom: bottom, trailing: right) 80 | } 81 | } 82 | 83 | extension UIApplication { 84 | public var screenSize: CGSize { 85 | let scenes = UIApplication.shared.connectedScenes 86 | let windowScene = scenes.first as? UIWindowScene 87 | let window = windowScene?.windows.first 88 | return window?.screen.bounds.size ?? UIScreen.main.bounds.size 89 | } 90 | 91 | var safeAreaInsets: UIEdgeInsets { 92 | let scenes = UIApplication.shared.connectedScenes 93 | let windowScene = scenes.first as? UIWindowScene 94 | let window = windowScene?.windows.first 95 | return window?.safeAreaInsets ?? UIEdgeInsets.zero 96 | } 97 | } 98 | 99 | extension Color { 100 | var textColor: Color { 101 | var r, g, b, a: CGFloat 102 | (r, g, b, a) = (0, 0, 0, 0) 103 | UIColor(self).getRed(&r, green: &g, blue: &b, alpha: &a) 104 | let brightness = ((r * 299) + (g * 587) + (b * 114)) / 1000; 105 | 106 | return brightness < 0.6 ? .white : .black 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Sources/VerticalSplit/Modifiers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Modifiers.swift 3 | // VerticalSplit 4 | // 5 | // Created by Vedant Gurav on 28/02/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public extension VerticalSplit { 11 | /// Accessory buttons shown to the left of the drag indicator in the VerticalSplit. 12 | /// - Parameter accessories: Accessories with their appearances and associated actions. 13 | func leadingAccessories(_ accessories: [SplitAccessory]) -> Self { 14 | var copy = self 15 | copy.leadingAccessories = accessories 16 | copy.leadingCount = accessories.count 17 | return copy 18 | } 19 | 20 | /// Accessory buttons shown to the left of the drag indicator in the VerticalSplit. An accessory is automatically added to open the menu if any MenuAccessories are provided. 21 | /// - Parameter accessories: Accessories with their appearances and associated actions. 22 | func trailingAccessories(_ accessories: [SplitAccessory]) -> Self { 23 | var copy = self 24 | copy.trailingAccessories = accessories 25 | copy.trailingCount = accessories.count + (menuAccessories.isEmpty ? 0 : 1) 26 | return copy 27 | } 28 | 29 | /// Larger accessory buttons shown in a pop-out menu in the VerticalSplit. A trailing accessory is automatically added to open the menu. 30 | /// - Parameters: 31 | /// - buttonSystemName: SFSymbol for the label of the trailing accessory that opens the menu. 32 | /// - buttonColor: Foreground color applied to the label of the button. 33 | /// - accessories: Accessories with their appearances and associated actions 34 | func menuAccessories( 35 | systemName: String = "plus.circle.fill", 36 | _ accessories: [MenuAccessory] 37 | ) -> Self { 38 | var copy = self 39 | copy.menuSymbol = systemName 40 | copy.menuAccessories = accessories 41 | copy.trailingCount = copy.trailingAccessories.count + (accessories.isEmpty ? 0 : 1) 42 | return copy 43 | } 44 | 45 | /// Control whether or not logs are made for debugging. 46 | /// - Parameter isEnabled: Set whether logging is enabled. 47 | func debug(_ isEnabled: Bool) -> Self { 48 | var copy = self 49 | copy.shouldLog = true 50 | return copy 51 | } 52 | 53 | /// Sets the background color for the top and bottom view containers, as well as the menu buttons, 54 | /// - Parameter color: The preferred background color. 55 | func backgroundColor(_ color: Color) -> Self { 56 | var copy = self 57 | copy.bgColor = color 58 | copy.textColor = color.textColor 59 | return copy 60 | } 61 | } 62 | 63 | -------------------------------------------------------------------------------- /Sources/VerticalSplit/SplitDetent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SplitDetent.swift 3 | // VerticalSplit 4 | // 5 | // Created by Vedant Gurav on 28/02/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A type that represents how the top and bottom views are split in the VerticalSplit. 11 | public enum SplitDetent: Equatable { 12 | /// A detent when the top view fills the entirety of the screen. A pill is shown at the bottom of the screen with accessories and the title of the bottom view. 13 | case topFull 14 | /// A detent when the bottom view fills the entirety of the screen. A pill is shown at the top of the screen with accessories and the title of the top view. 15 | case bottomFull 16 | /// A detent when the bottom view fills the mosy of the screen. The mini overlay for the top view is shown. 17 | case topMini 18 | /// A detent when the top view fills the mosy of the screen. The mini overlay for the vottom view is shown. 19 | case bottomMini 20 | /// A detent where the specified value represents the proportion of the screen occupied by the top view. The value is within 0 and 1. 21 | case fraction(_ value: Double) 22 | 23 | /// A textual representation of the detent. 24 | var description: String { 25 | if case let .fraction(value) = self { 26 | return String(format: "fraction(%.3f)", value) 27 | } 28 | return "\(self)" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/VerticalSplit/VerticalSplit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SplitscreenContainer.swift 3 | // VerticalSplit 4 | // 5 | // Created by Vedant Gurav on 03/02/2024. 6 | // 7 | 8 | import SwiftUI 9 | import OSLog 10 | 11 | let detentLogger = Logger(subsystem: "VerticalSplit", category: "Detents") 12 | let actionLogger = Logger(subsystem: "VerticalSplit", category: "Accessories") 13 | 14 | let spacing: CGFloat = 36 15 | let lil: CGFloat = 58 16 | let lil2: CGFloat = 58 * 3 / 2 17 | let lil3: CGFloat = 58 * 2 18 | let notches: Int = 6 19 | 20 | let lightImpact = UIImpactFeedbackGenerator(style: .light) 21 | let heavyImpact = UIImpactFeedbackGenerator(style: .heavy) 22 | let mediumImpact = UIImpactFeedbackGenerator(style: .medium) 23 | let rigidImpact = UIImpactFeedbackGenerator(style: .rigid) 24 | let softImpact = UIImpactFeedbackGenerator(style: .soft) 25 | 26 | /// A container that presents two views stacked vertically with an adjustable split. 27 | public struct VerticalSplit< 28 | TopView: View, 29 | BottomView: View, 30 | TopViewOverlay: View, 31 | BottomViewOverlay: View 32 | >: View { 33 | @ViewBuilder var topView: () -> TopView 34 | @ViewBuilder var bottomView: () -> BottomView 35 | 36 | @ViewBuilder var topViewOverlay: () -> TopViewOverlay 37 | @ViewBuilder var bottomViewOverlay: () -> BottomViewOverlay 38 | 39 | let autoTopOverlay: Bool 40 | let autoBottomOverlay: Bool 41 | 42 | let topTitle: String 43 | let bottomTitle: String 44 | 45 | var leadingAccessories: [SplitAccessory] = [] 46 | var trailingAccessories: [SplitAccessory] = [] 47 | var menuAccessories: [MenuAccessory] = [] 48 | var menuSymbol: String = "plus.circle.fill" 49 | 50 | var leadingCount: Int = 0 51 | var trailingCount: Int = 0 52 | 53 | @Binding var detent: SplitDetent 54 | @State var didSetInitialSplit = false 55 | 56 | var shouldLog = false 57 | var bgColor: Color = { 58 | Color(uiColor: .init(dynamicProvider: { trait in 59 | switch trait.userInterfaceStyle { 60 | case .dark: 61 | return .init(white: 0.16, alpha: 1) 62 | default: 63 | return .systemBackground 64 | } 65 | })) 66 | }() 67 | var textColor: Color = .primary 68 | 69 | @GestureState var isDragging: Bool = false 70 | 71 | @State var partition: CGFloat = 0 72 | @State var notchPartition: CGFloat = 0 73 | @State var initialPartition: CGFloat? 74 | @State var topHeight: CGFloat = (UIScreen.main.bounds.height - SafeAreaInsetsKey.defaultValue.vertical) / 2 - spacing / 2 75 | @State var currentSpacing: CGFloat = 36 76 | 77 | @State var hideTop = false 78 | @State var hideBottom = false 79 | 80 | @State var overscroll: CGFloat = 0 81 | @State var translationBeforeOverscroll: CGFloat = 0 82 | @State var initialMinimal = false 83 | @State var initialTop: Bool = false 84 | 85 | 86 | let bottomExtraOffset: CGFloat = { 87 | SafeAreaInsetsKey.defaultValue.bottom == 0 ? 16 : 0 88 | }() 89 | 90 | var cardHeight: CGFloat { 91 | (UIScreen.main.bounds.height - SafeAreaInsetsKey.defaultValue.vertical) / 2 - currentSpacing / 2 92 | } 93 | 94 | let range: CGFloat = { 95 | let defaultCardHeight = (UIScreen.main.bounds.height - SafeAreaInsetsKey.defaultValue.vertical) / 2 - spacing / 2 96 | return defaultCardHeight - lil 97 | }() 98 | 99 | let transaction: Transaction = { 100 | var transaction = Transaction(animation: .smooth(duration: 0.4)) 101 | transaction.tracksVelocity = true 102 | transaction.isContinuous = true 103 | return transaction 104 | }() 105 | 106 | // MARK: Gesture 107 | 108 | var bossGesture: some Gesture { 109 | DragGesture(minimumDistance: 0, coordinateSpace: .global) 110 | .updating($isDragging) { _, s, _ in 111 | s = true 112 | } 113 | .onChanged { value in 114 | if initialPartition == nil { 115 | initialPartition = partition 116 | if hideTop || hideBottom { 117 | initialMinimal = true 118 | initialTop = hideTop 119 | mediumImpact.impactOccurred(intensity: 0.6) 120 | } 121 | } 122 | 123 | withTransaction(transaction) { 124 | let translation = (initialPartition ?? 0) + value.translation.height 125 | let minimalAdjustment = (initialMinimal ? (initialTop ? 8 - lil : lil - 8 - bottomExtraOffset) : 0) 126 | let newPartition = min(cardHeight - lil, max(-cardHeight + lil, translation + minimalAdjustment)) 127 | 128 | if translation < -cardHeight + lil { 129 | if translationBeforeOverscroll == 0 { 130 | translationBeforeOverscroll = translation 131 | mediumImpact.impactOccurred(intensity: 0.8) 132 | } 133 | overscroll = (translation - translationBeforeOverscroll) * 0.75 134 | } else if translation > cardHeight - lil { 135 | if translationBeforeOverscroll == 0 { 136 | translationBeforeOverscroll = translation 137 | mediumImpact.impactOccurred(intensity: 0.8) 138 | } 139 | overscroll = (translation - translationBeforeOverscroll) * 0.75 140 | } else { 141 | translationBeforeOverscroll = 0 142 | overscroll = 0 143 | } 144 | 145 | hideTop = false 146 | hideBottom = false 147 | topHeight = cardHeight + newPartition 148 | let oldPartition = partition 149 | partition = newPartition 150 | notchPartition = getSnappedPartition(for: getNotch(for: newPartition)) 151 | 152 | if (oldPartition < notchPartition && notchPartition < partition) || 153 | (oldPartition > notchPartition && notchPartition > partition) { 154 | rigidImpact.impactOccurred(intensity: 0.5) 155 | } 156 | } 157 | } 158 | .onEnded { value in 159 | if value.translation.height < 2 { 160 | withTransaction(transaction) { 161 | if hideTop { 162 | hideTop = false 163 | } else { 164 | hideBottom = false 165 | } 166 | } 167 | } 168 | let translation = (initialPartition ?? 0) + value.translation.height 169 | let minimalAdjustment = (initialMinimal ? (initialTop ? 8 - lil : lil - 8 - bottomExtraOffset) : 0) 170 | var newPartition = translation + minimalAdjustment 171 | 172 | var newSplit: SplitDetent 173 | 174 | if newPartition < -cardHeight + lil2 { 175 | newPartition = lil - cardHeight 176 | newSplit = .topMini 177 | } else if newPartition < -cardHeight + lil3 { 178 | newPartition = lil3 - cardHeight 179 | newSplit = .fraction(0) 180 | } else if newPartition > cardHeight - lil2 { 181 | newPartition = cardHeight - lil 182 | newSplit = .bottomMini 183 | } else if newPartition > cardHeight - lil3 { 184 | newPartition = cardHeight - lil3 185 | newSplit = .fraction(1) 186 | } else { 187 | let notch = getNotch(for: newPartition) 188 | newSplit = .fraction(CGFloat(notch) / CGFloat(notches)) 189 | newPartition = getSnappedPartition(for: notch) 190 | } 191 | 192 | withTransaction(transaction) { 193 | if initialMinimal && (hideTop || hideBottom) { 194 | overscroll = 0 195 | return 196 | } 197 | partition = newPartition 198 | topHeight = cardHeight + newPartition 199 | if overscroll < -20 { 200 | hideTop = true 201 | newSplit = .bottomFull 202 | partition = -(cardHeight - lil) 203 | hideBottom = false 204 | mediumImpact.impactOccurred(intensity: 0.6) 205 | } else if overscroll > 20 { 206 | hideTop = false 207 | newSplit = .topFull 208 | partition = (cardHeight - lil) 209 | hideBottom = true 210 | mediumImpact.impactOccurred(intensity: 0.6) 211 | } else { 212 | hideTop = false 213 | hideBottom = false 214 | rigidImpact.impactOccurred(intensity: 0.6) 215 | } 216 | overscroll = 0 217 | translationBeforeOverscroll = 0 218 | } 219 | initialPartition = nil 220 | initialMinimal = hideTop || hideBottom 221 | initialTop = false 222 | detent = newSplit 223 | } 224 | } 225 | 226 | // MARK: Body 227 | 228 | public var body: some View { 229 | let isAccessoriesPill: Bool = currentSpacing != spacing 230 | let isMinimalPill: Bool = hideTop || hideBottom 231 | ZStack { 232 | VStack(spacing: currentSpacing) { 233 | if !hideTop { 234 | TopWrapper( 235 | minimise: (min(lil3, topHeight + (isAccessoriesPill ? spacing / 2 : 0) ) - lil) / lil, 236 | overscroll: overscroll, 237 | isFull: hideBottom, 238 | isShowingAccessories: isAccessoriesPill, 239 | bgColor: bgColor, 240 | content: topView, 241 | overlay: { 242 | if autoTopOverlay { 243 | Text(topTitle) 244 | .padding(.horizontal) 245 | .fontWeight(.semibold) 246 | } else { 247 | topViewOverlay() 248 | } 249 | } 250 | ) 251 | .frame(height: hideBottom ? nil : topHeight + overscroll / 5 ) 252 | .transaction(value: hideBottom, { t in 253 | t.animation = didSetInitialSplit ? .smooth(duration: 0.4) : .none 254 | }) 255 | .transition(.offset(y: -topHeight - (partition > 0 ? 300 : 200) )) 256 | .zIndex(1) 257 | } 258 | if !hideBottom { 259 | BottomWrapper( 260 | minimise: 1 - max(0, partition - cardHeight + lil3 - (isAccessoriesPill ? spacing / 2 : 0)) / lil, 261 | overscroll: overscroll, 262 | isFull: hideTop, 263 | isShowingAccessories: isAccessoriesPill, 264 | bgColor: bgColor, 265 | content: bottomView, 266 | overlay: { 267 | if autoBottomOverlay { 268 | Text(bottomTitle) 269 | .padding(.horizontal) 270 | .fontWeight(.semibold) 271 | } else { 272 | bottomViewOverlay() 273 | } 274 | } 275 | ) 276 | .transaction(value: hideBottom, { t in 277 | t.animation = didSetInitialSplit ? .smooth(duration: 0.4) : .none 278 | }) 279 | .transition(.offset(y: -partition + range + (partition < 0 ? 300 : 200) )) 280 | .zIndex(1) 281 | } 282 | } 283 | .animation(.smooth(duration: 0.45), value: hideTop) 284 | .animation(.smooth(duration: 0.45), value: hideBottom) 285 | .overlay { 286 | if currentSpacing != spacing { 287 | Color.black.opacity(0.4) 288 | .ignoresSafeArea() 289 | .onTapGesture { 290 | withTransaction(transaction) { 291 | currentSpacing = spacing 292 | topHeight = cardHeight + partition 293 | } 294 | if shouldLog { 295 | actionLogger.info("Menu dismissed") 296 | } 297 | } 298 | } 299 | } 300 | .zIndex(1) 301 | 302 | HStack(spacing: 8) { 303 | Spacer() 304 | .frame(width: max(0, (20 + 8) * CGFloat(leadingCount) - 8)) 305 | Text(isMinimalPill ? (hideTop ? topTitle : bottomTitle) : "") 306 | .fontWeight(.medium) 307 | .fixedSize() 308 | .padding(.horizontal, 8) 309 | .opacity(0) 310 | .foregroundStyle(.white) 311 | 312 | Spacer() 313 | .frame(width: max(0, (20 + 8) * CGFloat(trailingCount) - 8)) 314 | 315 | } 316 | .padding(.horizontal, 12) 317 | .frame(height: isMinimalPill ? 44 : currentSpacing) 318 | .frame(maxWidth: isMinimalPill ? nil : .infinity) 319 | .background(Capsule().fill(.black)) 320 | .offset( 321 | y: (hideTop ? -lil + 8 : hideBottom ? lil - 8 - bottomExtraOffset : 0) 322 | + (partition + overscroll / (hideTop || hideBottom ? 1 : 5)) 323 | ) 324 | .zIndex(2) 325 | 326 | 327 | HStack(spacing: 8) { 328 | HStack(spacing: 8) { 329 | ForEach(leadingAccessories) { accessory in 330 | Button(action: { 331 | accessory.action() 332 | lightImpact.impactOccurred(intensity: 0.5) 333 | if shouldLog { 334 | actionLogger.info("Leading accessory action: \(accessory.title)") 335 | } 336 | }) { 337 | Image(systemName: accessory.systemName) 338 | .resizable() 339 | .scaledToFit() 340 | .foregroundStyle(accessory.color) 341 | .frame(width: 20, height: 20) 342 | } 343 | .buttonStyle(ScaleDownButtonStyle()) 344 | } 345 | } 346 | 347 | Text(isMinimalPill ? (hideTop ? topTitle : bottomTitle) : (topHeight < cardHeight ? topTitle : bottomTitle)) 348 | .padding(.horizontal, 8) 349 | .fixedSize() 350 | .opacity(0) 351 | .frame(maxWidth: isMinimalPill ? nil : .infinity) 352 | 353 | 354 | HStack(spacing: 8) { 355 | ForEach(trailingAccessories) { accessory in 356 | Button(action: { 357 | accessory.action() 358 | lightImpact.impactOccurred(intensity: 0.5) 359 | if shouldLog { 360 | actionLogger.info("Trailing accessory action: \(accessory.title)") 361 | } 362 | }) { 363 | Image(systemName: accessory.systemName) 364 | .resizable() 365 | .scaledToFit() 366 | .foregroundStyle(accessory.color) 367 | .frame(width: 20, height: 20) 368 | } 369 | .buttonStyle(ScaleDownButtonStyle()) 370 | } 371 | if !menuAccessories.isEmpty { 372 | Button { 373 | mediumImpact.impactOccurred(intensity: 0.5) 374 | withTransaction(transaction) { 375 | if currentSpacing == spacing { 376 | currentSpacing = spacing * 2 377 | } else { 378 | currentSpacing = spacing 379 | } 380 | topHeight = cardHeight + partition 381 | if shouldLog { 382 | actionLogger.info("Menu opened") 383 | } 384 | } 385 | } label: { 386 | Image(systemName: menuSymbol) 387 | .resizable() 388 | .scaledToFit() 389 | .frame(width: 20, height: 20) 390 | } 391 | .buttonStyle(ScaleDownButtonStyle()) 392 | } 393 | } 394 | } 395 | .fontWeight(.medium) 396 | .foregroundStyle(.white) 397 | .padding(.horizontal, isMinimalPill ? 12 : 24 + abs(overscroll / 20)) 398 | .frame(height: isMinimalPill ? 44 : currentSpacing) 399 | .scaleEffect(isAccessoriesPill ? 0.9 : 1) 400 | .blur(radius: isAccessoriesPill ? 12 : 0) 401 | .opacity(isAccessoriesPill ? 0 : 1) 402 | .frame(maxWidth: .infinity, alignment: .center) 403 | .overlay(alignment: .center) { 404 | HStack(spacing: 8) { 405 | ForEach(menuAccessories) { accessory in 406 | Button { 407 | mediumImpact.impactOccurred(intensity: 0.5) 408 | withTransaction(transaction) { 409 | currentSpacing = spacing 410 | topHeight = cardHeight + partition 411 | } 412 | if shouldLog { 413 | actionLogger.info("Menu accessory action: \(accessory.title)") 414 | } 415 | accessory.action() 416 | } label: { 417 | HStack(spacing: 4) { 418 | Image(systemName: accessory.systemName) 419 | .resizable() 420 | .scaledToFit() 421 | .foregroundStyle(accessory.color) 422 | .frame(width: 16, height: 16) 423 | Text(accessory.title) 424 | .foregroundStyle(textColor) 425 | .lineLimit(1) 426 | .minimumScaleFactor(0.7) 427 | .frame(maxWidth: .infinity) 428 | } 429 | .fontWeight(.medium) 430 | .padding(.leading, 14) 431 | .padding(.trailing, 24) 432 | .frame(maxHeight: .infinity) 433 | .background(Capsule().fill(bgColor)) 434 | } 435 | .buttonStyle(ScaleDownButtonStyle()) 436 | } 437 | } 438 | .padding(.horizontal, 8) 439 | .padding(.vertical, 14) 440 | .frame(height: spacing * 2) 441 | .scaleEffect(isAccessoriesPill ? 1 : 0.6) 442 | .blur(radius: isAccessoriesPill ? 0 : 8) 443 | .opacity(isAccessoriesPill ? 1 : 0) 444 | .offset(y: isAccessoriesPill ? 0 : hideTop ? -120 : hideBottom ? 120 : 0) 445 | .frame(height: currentSpacing) 446 | .frame(maxWidth: min(.infinity, 400)) 447 | } 448 | .contentShape(.rect) 449 | .offset( 450 | y: (hideTop ? -lil + 8 : hideBottom ? lil - 8 - bottomExtraOffset : 0) 451 | + (partition + overscroll / (hideTop || hideBottom ? 1 : 5)) 452 | ) 453 | .gesture(currentSpacing == spacing ? bossGesture : nil) 454 | .zIndex(10) 455 | 456 | 457 | ZStack { 458 | Capsule() 459 | .fill(.white.opacity(0.3)) 460 | .frame(width: 56, height: 5) 461 | .transaction({ t in 462 | t.animation = .easeInOut(duration: 0.3) 463 | }, body: { $0.scaleEffect(isDragging ? 0.9 : 1) }) 464 | .blur(radius: isMinimalPill ? 8 : 0) 465 | .opacity(isMinimalPill ? 0 : 1) 466 | Text(isMinimalPill ? (hideTop ? topTitle : bottomTitle) : (topHeight < cardHeight ? topTitle : bottomTitle)) 467 | .fontWeight(.medium) 468 | .fixedSize() 469 | .scaleEffect(isMinimalPill ? 1 : 0.9) 470 | .blur(radius: isMinimalPill ? 0 : 12) 471 | .opacity(isMinimalPill ? 1 : 0) 472 | .foregroundStyle(.white) 473 | .offset(x: CGFloat(leadingCount - trailingCount) * (20 + 8) / 2) 474 | } 475 | .scaleEffect(1) 476 | .scaleEffect(isAccessoriesPill ? 0.9 : 1) 477 | .blur(radius: isAccessoriesPill ? 12 : 0) 478 | .opacity(isAccessoriesPill ? 0 : 1) 479 | .offset( 480 | y: (hideTop ? -lil + 8 : hideBottom ? lil - 8 - bottomExtraOffset : 0) 481 | + (partition + overscroll / (hideTop || hideBottom ? 1 : 5)) 482 | ) 483 | .zIndex(11) 484 | .allowsHitTesting(false) 485 | 486 | } 487 | .background(.black) 488 | .onAppear { 489 | didUpdateSplit(split: detent) 490 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { 491 | didSetInitialSplit = true 492 | } 493 | } 494 | .onChange(of: detent) { _, newValue in 495 | guard !isAccessoriesPill else { return } 496 | var t = transaction 497 | t.animation = .smooth(duration: 0.5) 498 | withTransaction(t) { 499 | didUpdateSplit(split: detent) 500 | } 501 | } 502 | } 503 | 504 | // MARK: Functions 505 | 506 | func didUpdateSplit(split: SplitDetent) { 507 | detentLogger.info("SplitDetent: \(split.description)") 508 | currentSpacing = spacing 509 | hideTop = false 510 | hideBottom = false 511 | switch split { 512 | case .topFull: 513 | hideBottom = true 514 | partition = cardHeight - lil 515 | case .bottomFull: 516 | hideTop = true 517 | partition = -cardHeight + lil 518 | case .topMini: 519 | partition = -range 520 | case .bottomMini: 521 | partition = range 522 | case .fraction(let value): 523 | if value < 0 { 524 | detentLogger.warning("SplitDetent: Invalid value, fraction should be in range 0...1") 525 | self.detent = .bottomFull 526 | } else if value > 1 { 527 | detentLogger.warning("SplitDetent: Invalid value, fraction should be in range 0...1") 528 | self.detent = .topFull 529 | } else { 530 | let notch = Int(round(CGFloat(notches) * value)) 531 | partition = getSnappedPartition(for: notch) + (value == 0 ? lil : value == 1 ? -lil : 0) 532 | } 533 | } 534 | topHeight = cardHeight + partition 535 | } 536 | 537 | func getNotch(for partition: CGFloat) -> Int { 538 | if partition < -range { 539 | return 0 540 | } else if partition > range { 541 | return notches 542 | } 543 | let progress = Int(round((partition + range) / (range * 2) * CGFloat(notches))) 544 | return progress 545 | } 546 | 547 | func getSnappedPartition(for notch: Int) -> CGFloat { 548 | let p = CGFloat(notch) / CGFloat(notches) * range * 2 - range 549 | return p 550 | } 551 | 552 | // MARK: Initialisers 553 | 554 | /// Creates a VerticalSplit with top and bottom views and a custom overlay for the top view when minimised. 555 | /// - Parameters: 556 | /// - detent: A binding for controlling the split. 557 | /// - topTitle: A title describing the top view, shown when the view is minimised. 558 | /// - bottomTitle: A title describing the bottom view, shown when the view is minimised. 559 | /// - topView: The content shown in the top view. 560 | /// - bottomView: The content shown in the bottom view. 561 | /// - topMiniOverlay: A custom overlay for the top view when minimised. 562 | /// - bottomMiniOverlay: A custom overlay for the bottom view when minimised. 563 | public init( 564 | detent: Binding = .constant(.fraction(0.5)), 565 | topTitle: String, 566 | bottomTitle: String, 567 | topView: @escaping () -> TopView, 568 | bottomView: @escaping () -> BottomView, 569 | topMiniOverlay: @escaping () -> TopViewOverlay, 570 | bottomMiniOverlay: @escaping () -> BottomViewOverlay 571 | ) { 572 | self._detent = detent 573 | self.topView = topView 574 | self.bottomView = bottomView 575 | self.topViewOverlay = topMiniOverlay 576 | self.bottomViewOverlay = bottomMiniOverlay 577 | self.topTitle = topTitle.isEmpty ? "Top" : topTitle 578 | self.bottomTitle = bottomTitle.isEmpty ? "Bottom" : bottomTitle 579 | self.autoTopOverlay = false 580 | self.autoBottomOverlay = false 581 | } 582 | 583 | /// Creates a VerticalSplit with top and bottom views and a custom overlay for the top view when minimised. 584 | /// - Parameters: 585 | /// - detent: A binding for controlling the split. 586 | /// - topTitle: A title describing the top view, shown when the view is minimised. 587 | /// - bottomTitle: A title describing the bottom view, shown when the view is minimised. 588 | /// - topView: The content shown in the top view. 589 | /// - bottomView: The content shown in the bottom view. 590 | /// - topMiniOverlay: A custom overlay for the top view when minimised. 591 | public init( 592 | detent: Binding = .constant(.fraction(0.5)), 593 | topTitle: String, 594 | bottomTitle: String, 595 | topView: @escaping () -> TopView, 596 | bottomView: @escaping () -> BottomView, 597 | topMiniOverlay: @escaping () -> TopViewOverlay 598 | ) where BottomViewOverlay == EmptyView { 599 | self.topView = topView 600 | self.bottomView = bottomView 601 | self.topViewOverlay = topMiniOverlay 602 | self.bottomViewOverlay = EmptyView.init 603 | self.topTitle = topTitle.isEmpty ? "Top" : topTitle 604 | self.bottomTitle = bottomTitle.isEmpty ? "Bottom" : bottomTitle 605 | self.autoTopOverlay = false 606 | self.autoBottomOverlay = true 607 | self._detent = detent 608 | } 609 | 610 | /// Creates a VerticalSplit with top and bottom views and a custom overlay for the bottom view when minimised. 611 | /// - Parameters: 612 | /// - detent: A binding for controlling the split. 613 | /// - topTitle: A title describing the top view, shown when the view is minimised. 614 | /// - bottomTitle: A title describing the bottom view, shown when the view is minimised. 615 | /// - topView: The content shown in the top view. 616 | /// - bottomView: The content shown in the bottom view. 617 | /// - bottomMiniOverlay: A custom overlay for the bottom view when minimised. 618 | public init( 619 | detent: Binding = .constant(.fraction(0.5)), 620 | topTitle: String, 621 | bottomTitle: String, 622 | topView: @escaping () -> TopView, 623 | bottomView: @escaping () -> BottomView, 624 | bottomMiniOverlay: @escaping () -> BottomViewOverlay 625 | ) where TopViewOverlay == EmptyView { 626 | self.topView = topView 627 | self.bottomView = bottomView 628 | self.topViewOverlay = EmptyView.init 629 | self.bottomViewOverlay = bottomMiniOverlay 630 | self.topTitle = topTitle.isEmpty ? "Top" : topTitle 631 | self.bottomTitle = bottomTitle.isEmpty ? "Bottom" : bottomTitle 632 | self.autoTopOverlay = true 633 | self.autoBottomOverlay = false 634 | self._detent = detent 635 | } 636 | 637 | 638 | /// Creates a VerticalSplit with top and bottom views. 639 | /// - Parameters: 640 | /// - detent: A binding for controlling the split. 641 | /// - topTitle: A title describing the top view, shown when the view is minimised. 642 | /// - bottomTitle: A title describing the bottom view, shown when the view is minimised. 643 | /// - topView: The content shown in the top view. 644 | /// - bottomView: The content shown in the bottom view. 645 | public init( 646 | detent: Binding = .constant(.fraction(0.5)), 647 | topTitle: String, 648 | bottomTitle: String, 649 | topView: @escaping () -> TopView, 650 | bottomView: @escaping () -> BottomView 651 | ) where TopViewOverlay == EmptyView, BottomViewOverlay == EmptyView { 652 | self.topView = topView 653 | self.bottomView = bottomView 654 | self.topViewOverlay = EmptyView.init 655 | self.bottomViewOverlay = EmptyView.init 656 | self.topTitle = topTitle.isEmpty ? "Top" : topTitle 657 | self.bottomTitle = bottomTitle.isEmpty ? "Bottom" : bottomTitle 658 | self.autoTopOverlay = true 659 | self.autoBottomOverlay = true 660 | self._detent = detent 661 | } 662 | 663 | } 664 | -------------------------------------------------------------------------------- /Sources/VerticalSplit/Wrappers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Wrappers.swift 3 | // VerticalSplit 4 | // 5 | // Created by Vedant Gurav on 28/02/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TopWrapper: View { 11 | var minimise: CGFloat 12 | var overscroll: CGFloat 13 | var isFull: Bool 14 | var isShowingAccessories: Bool 15 | var bgColor: Color 16 | @ViewBuilder var content: () -> Content 17 | @ViewBuilder var overlay: () -> Overlay 18 | 19 | let bottomSafeArea = SafeAreaInsetsKey.defaultValue.smartBottom 20 | let displayCornerRadius = UIScreen.main.displayCornerRadius 21 | let screenWidth = UIApplication.shared.screenSize.width 22 | 23 | var cornerRadius: CGFloat { 24 | isFull && !isShowingAccessories ? displayCornerRadius + overscroll * 2 : 22 25 | } 26 | 27 | var body: some View { 28 | GeometryReader { _ in 29 | ZStack { 30 | content() 31 | } 32 | .frame(maxWidth: screenWidth, maxHeight: .infinity, alignment: .top) 33 | .safeAreaPadding(.top, SafeAreaInsetsKey.defaultValue.top) 34 | .safeAreaPadding(.bottom, isFull && !isShowingAccessories ? lil + SafeAreaInsetsKey.defaultValue.bottom - 8 : 0) 35 | } 36 | .scaleEffect(1 - (1 - minimise) * 0.15, anchor: .top) 37 | .blur(radius: (1 - minimise) * 8) 38 | .overlay { bgColor.opacity(1 - minimise).allowsHitTesting(false) } 39 | .overlay(alignment: .bottom, content: { 40 | overlay() 41 | .frame(maxWidth: .infinity, alignment: .leading) 42 | .frame(height: lil) 43 | .opacity(1 - minimise) 44 | .blur(radius: minimise * 8) 45 | .offset(y: 16 * minimise) 46 | .scaleEffect(1 + minimise * 0.15) 47 | .allowsHitTesting(minimise == 0) 48 | }) 49 | .mask { RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) } 50 | .background { 51 | RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) 52 | .fill(bgColor) 53 | .padding(.top, -200) 54 | } 55 | .offset(y: isShowingAccessories && isFull ? -(spacing * 2 + bottomSafeArea) : 0) 56 | .scaleEffect(isFull ? 1 : 1 + min(0, overscroll / 800), anchor: isFull ? .center : .bottom) 57 | .ignoresSafeArea() 58 | } 59 | } 60 | 61 | struct BottomWrapper: View { 62 | var minimise: CGFloat 63 | var overscroll: CGFloat 64 | var isFull: Bool 65 | var isShowingAccessories: Bool 66 | var bgColor: Color 67 | @ViewBuilder var content: () -> Content 68 | @ViewBuilder var overlay: () -> Overlay 69 | 70 | let topSafeArea = SafeAreaInsetsKey.defaultValue.top 71 | let displayCornerRadius = UIScreen.main.displayCornerRadius 72 | let screenWidth = UIApplication.shared.screenSize.width 73 | 74 | var cornerRadius: CGFloat { 75 | isFull && !isShowingAccessories ? displayCornerRadius - overscroll * 2 : 22 76 | } 77 | 78 | var body: some View { 79 | GeometryReader { _ in 80 | ZStack { 81 | content() 82 | } 83 | .frame(maxWidth: screenWidth, maxHeight: .infinity, alignment: .top) 84 | .safeAreaPadding(.top, isFull && !isShowingAccessories ? lil + SafeAreaInsetsKey.defaultValue.top - 8 : 0) 85 | .safeAreaPadding(.bottom, SafeAreaInsetsKey.defaultValue.bottom) 86 | } 87 | .scaleEffect(1 - (1 - minimise) * 0.15, anchor: .bottom) 88 | .blur(radius: (1 - minimise) * 8) 89 | .overlay { bgColor.opacity(1 - minimise).allowsHitTesting(false) } 90 | .overlay(alignment: .top, content: { 91 | overlay() 92 | .frame(maxWidth: .infinity, alignment: .leading) 93 | .frame(height: lil) 94 | .opacity(1 - minimise) 95 | .blur(radius: minimise * 8) 96 | .offset(y: -16 * minimise) 97 | .scaleEffect(1 + minimise * 0.15) 98 | .allowsHitTesting(minimise == 0) 99 | }) 100 | .mask { RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) } 101 | .background { 102 | RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) 103 | .fill(bgColor) 104 | .padding(.bottom, -200) 105 | } 106 | .offset(y: isShowingAccessories && isFull ? (spacing * 2 + topSafeArea) : 0) 107 | .scaleEffect(isFull ? 1 : 1 - max(0, overscroll / 800), anchor: isFull ? .center : .top) 108 | .ignoresSafeArea() 109 | } 110 | } 111 | --------------------------------------------------------------------------------