├── .gitignore ├── Package.swift ├── README.md └── Sources └── AdaptiveSheet ├── AdaptiveSheet.swift └── AdaptiveSheetPreview.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 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 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: "AdaptiveSheet", 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: "AdaptiveSheet", 13 | targets: ["AdaptiveSheet"] 14 | ), 15 | ], 16 | targets: [ 17 | // Targets are the basic building blocks of a package, defining a module or a test suite. 18 | // Targets can depend on other targets in this package and products from dependencies. 19 | .target( 20 | name: "AdaptiveSheet"), 21 | 22 | ] 23 | 24 | ) 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Adaptive Sheets 2 | Sheets are a widely used UI component on iOS and allow for modal content to be easily viewed, interacted with, and dismissed. 3 | 4 | However, even when presented with smaller detent heights, these sheets can still feel heavy, obstructing content and breaking the user’s sense of context. 5 | 6 | Adaptive sheets try to bridge the gap between system sheets, and the custom presentation used when pairing a device such as AirPods. 7 | 8 | 9 | 10 | https://github.com/user-attachments/assets/f78f5b21-de9d-45d0-bfa0-32258aee7697 11 | 12 | 13 | 14 | ## Implementation 15 | Adaptive Sheets are built using the standard SwiftUI .sheet presentation, which preserves all the niceties like gestures, presentation bindings, and onDismiss callbacks. 16 | 17 | ## Usage 18 | Using an Adaptive Sheet is very similar to using a standard system sheet, however you do need to specify the container, as each has slight variations in its implementation. 19 | 20 | ### Alert 21 | The simplest form of Adaptive sheet is an Alert. It has no internal scroll view and so when overscrolled upwards, the entire card will move. Content in an Alert is vertically centered. 22 | 23 | ```swift 24 | func adaptiveAlert( 25 | isPresented: Binding, 26 | @ViewBuilder cardContent: @escaping (Binding, Binding) -> some View, 27 | onDismiss: (() -> Void)? = nil 28 | ) -> some View 29 | ``` 30 | 31 | ### ScrollView 32 | The contents are placed inside a ScrollView that can expand as the contents change. You can set a limit for the height that the sheet should not automatically grow larger than. If the content size exceeds this limit, the user will be able to expand the sheet to the large detent size. 33 | 34 | ```swift 35 | func adaptiveSheet( 36 | isPresented: Binding, 37 | dismissEnabled: Bool = true, 38 | adaptiveDetentLimit: CGFloat = 450, 39 | @ViewBuilder cardContent: @escaping (Binding, Binding) -> some View, 40 | @ViewBuilder bottomPinnedContent: @escaping (Binding, Binding) -> some View = { _,_ in EmptyView() }, 41 | fullHeightDidChange: @escaping (Bool) -> () = { _ in }, 42 | onDismiss: (() -> Void)? = nil 43 | ) -> some View 44 | ``` 45 | 46 | In practice, calling an adaptive sheet looks as simple as: 47 | 48 | ```swift 49 | .adaptiveSheet(isPresented: $isShowingList) { isPresented, detent in 50 | ContentView() 51 | } 52 | ``` 53 | 54 | ### NavigationScrollView 55 | The contents are placed inside a ScrollView, which itself is inside a NavigationStack. It proved impractical to make the sheet resize when navigation occurred, so sheets match the height provided, and can always expand to the .large detent size. 56 | 57 | ```swift 58 | func adaptiveNavigationSheet( 59 | isPresented: Binding, 60 | dismissEnabled: Bool = true, 61 | adaptiveDetentLimit: CGFloat = 450, 62 | @ViewBuilder cardContent: @escaping (Binding, Binding) -> some View, 63 | @ViewBuilder bottomPinnedContent: @escaping (Binding, Binding) -> some View = { _,_ in EmptyView() }, 64 | fullHeightDidChange: @escaping (Bool) -> () = { _ in }, 65 | onDismiss: (() -> Void)? = nil 66 | ) -> some View 67 | ``` 68 | 69 | ### NavigationListView 70 | Behaves the same as NavigationScrollView but uses a List instead of a ScrollView. 71 | ```swift 72 | func adaptiveNavigationListSheet( 73 | isPresented: Binding, 74 | dismissEnabled: Bool = true, 75 | adaptiveDetentLimit: CGFloat = 450, 76 | @ViewBuilder cardContent: @escaping (Binding, Binding) -> some View, 77 | @ViewBuilder bottomPinnedContent: @escaping (Binding, Binding) -> some View = { _,_ in EmptyView() }, 78 | fullHeightDidChange: @escaping (Bool) -> () = { _ in }, 79 | onDismiss: (() -> Void)? = nil 80 | ) -> some View 81 | ``` 82 | 83 | ## Known Issues: 84 | - NavigationScrollView and NavigationListView are a little wonky on iPad. 85 | 86 | ## Leave a tip: 87 | https://ko-fi.com/benricem 88 | 89 | or just say thanks: https://mastodon.social/@BenRiceM/ 90 | -------------------------------------------------------------------------------- /Sources/AdaptiveSheet/AdaptiveSheet.swift: -------------------------------------------------------------------------------- 1 | // The Swift Programming Language 2 | // https://docs.swift.org/swift-book 3 | 4 | 5 | import SwiftUI 6 | 7 | let isMacEnvironment: Bool = { 8 | ProcessInfo.processInfo.isiOSAppOnMac || ProcessInfo.processInfo.isMacCatalystApp 9 | }() 10 | 11 | enum AdaptiveStyle { 12 | case alert 13 | case scrollView 14 | case navigationScrollView 15 | case navigationListView 16 | } 17 | 18 | extension CGFloat { 19 | static let defaultDetentHeight: CGFloat = 200 20 | } 21 | 22 | public struct AdaptiveOptions : Sendable { 23 | var adaptiveDetentLimit: CGFloat 24 | var dismissEnabled: Bool 25 | var minimumFittingSize : CGSize 26 | 27 | public init( 28 | adaptiveDetentLimit: CGFloat = 450, 29 | dismissEnabled: Bool = true, 30 | minimumFittingSize: CGSize = CGSize(width: 320, height: 240) 31 | ) { 32 | self.adaptiveDetentLimit = adaptiveDetentLimit 33 | self.dismissEnabled = dismissEnabled 34 | self.minimumFittingSize = minimumFittingSize 35 | } 36 | 37 | public static let `default` = AdaptiveOptions() 38 | public static let alert = AdaptiveOptions( 39 | adaptiveDetentLimit: 120, 40 | minimumFittingSize: CGSize(width: 320, height: 60) 41 | ) 42 | } 43 | 44 | @available(iOS 17.0, *) 45 | extension View { 46 | public func adaptiveAlert( 47 | isPresented: Binding, 48 | options: AdaptiveOptions = .alert, 49 | @ViewBuilder cardContent: @escaping (Binding, Binding) -> some View, 50 | onDismiss: (() -> Void)? = nil 51 | ) -> some View { 52 | return modifier( 53 | AdaptiveModifier( 54 | style: .alert, 55 | isPresented: isPresented, 56 | options: options, 57 | cardContent: cardContent, 58 | bottomPinnedContent: { _,_ in EmptyView() }, 59 | fullHeightDidChange: { _ in }, 60 | onDismiss: onDismiss 61 | ) 62 | ) 63 | } 64 | 65 | public func adaptiveSheet( 66 | isPresented : Binding, 67 | options: AdaptiveOptions = .default, 68 | @ViewBuilder cardContent: @escaping (Binding, Binding) -> some View, 69 | @ViewBuilder bottomPinnedContent: @escaping (Binding, Binding) -> some View = { _,_ in EmptyView() }, 70 | fullHeightDidChange: @escaping (Bool) -> () = { _ in }, 71 | onDismiss: (() -> Void)? = nil 72 | ) -> some View { 73 | return modifier( 74 | AdaptiveModifier( 75 | style: .scrollView, 76 | isPresented: isPresented, 77 | options: options, 78 | cardContent: cardContent, 79 | bottomPinnedContent: bottomPinnedContent, 80 | fullHeightDidChange: fullHeightDidChange, 81 | onDismiss: onDismiss 82 | ) 83 | ) 84 | } 85 | 86 | public func adaptiveNavigationSheet( 87 | isPresented : Binding, 88 | options: AdaptiveOptions = .default, 89 | @ViewBuilder cardContent: @escaping (Binding, Binding) -> some View, 90 | @ViewBuilder bottomPinnedContent: @escaping (Binding, Binding) -> some View = { _,_ in EmptyView() }, 91 | fullHeightDidChange: @escaping (Bool) -> () = { _ in }, 92 | onDismiss: (() -> Void)? = nil 93 | ) -> some View { 94 | return modifier( 95 | AdaptiveModifier( 96 | style: .navigationScrollView, 97 | isPresented: isPresented, 98 | options: options, 99 | cardContent: cardContent, 100 | bottomPinnedContent: bottomPinnedContent, 101 | fullHeightDidChange: fullHeightDidChange, 102 | onDismiss: onDismiss 103 | ) 104 | ) 105 | } 106 | 107 | @available(iOS 18.0, *) 108 | public func adaptiveNavigationListSheet( 109 | isPresented : Binding, 110 | options: AdaptiveOptions = .default, 111 | @ViewBuilder cardContent: @escaping (Binding, Binding) -> some View, 112 | @ViewBuilder bottomPinnedContent: @escaping (Binding, Binding) -> some View = { _,_ in EmptyView() }, 113 | fullHeightDidChange: @escaping (Bool) -> () = { _ in }, 114 | onDismiss: (() -> Void)? = nil 115 | ) -> some View { 116 | return modifier( 117 | AdaptiveModifier( 118 | style: .navigationListView, 119 | isPresented: isPresented, 120 | options: options, 121 | cardContent: cardContent, 122 | bottomPinnedContent: bottomPinnedContent, 123 | fullHeightDidChange: fullHeightDidChange, 124 | onDismiss: onDismiss 125 | ) 126 | ) 127 | } 128 | } 129 | 130 | @available(iOS 17.0, *) 131 | struct AdaptiveModifier: ViewModifier { 132 | 133 | let style : AdaptiveStyle 134 | 135 | @Binding private var isPresented : Bool 136 | 137 | @State private var selectedDetent : PresentationDetent 138 | @State private var adaptiveDetent : PresentationDetent = .height(.defaultDetentHeight) 139 | @State private var adaptiveDetentTwo : PresentationDetent = .height(.defaultDetentHeight - 1) 140 | @State private var isExpandable: Bool = false 141 | 142 | private var dismissEnabled: Bool 143 | private let heightLimit: CGFloat? 144 | private var cardContent: (Binding, Binding) -> CardContent 145 | private var bottomPinnedContent: (Binding, Binding) -> PinnedContent? 146 | 147 | private var fullHeightDidChange: (Bool) -> () 148 | private var onDismiss: (() -> Void)? 149 | 150 | private var isLargeSheet : Bool { selectedDetent == .large } 151 | 152 | private var trueHeightLimit : CGFloat { 153 | let maxHeight = UIScreen.main.bounds.height - 100 154 | return min(heightLimit ?? 10000, maxHeight) 155 | } 156 | 157 | private var availableDetents : Set { 158 | return isExpandable && style != .alert 159 | ? [adaptiveDetent, adaptiveDetentTwo, .large] 160 | : [adaptiveDetent, adaptiveDetentTwo] 161 | } 162 | 163 | init( 164 | style: AdaptiveStyle, 165 | isPresented : Binding, 166 | options: AdaptiveOptions, 167 | cardContent: @escaping (Binding, Binding) -> CardContent, 168 | bottomPinnedContent: @escaping (Binding, Binding) -> PinnedContent?, 169 | fullHeightDidChange: @escaping (Bool) -> (), 170 | onDismiss: (() -> Void)? 171 | ) { 172 | self.style = style 173 | self._isPresented = isPresented 174 | self.dismissEnabled = options.dismissEnabled 175 | self.heightLimit = options.adaptiveDetentLimit 176 | self.minimumFittingSize = options.minimumFittingSize 177 | self.cardContent = cardContent 178 | self.bottomPinnedContent = bottomPinnedContent 179 | self.fullHeightDidChange = fullHeightDidChange 180 | self.onDismiss = onDismiss 181 | 182 | self.selectedDetent = .height(.defaultDetentHeight) 183 | } 184 | 185 | private var minimumFittingSize : CGSize 186 | 187 | func body(content: Content) -> some View { 188 | content 189 | .sheet(isPresented: $isPresented) { 190 | onDismiss?() 191 | selectedDetent = adaptiveDetent 192 | } content: { 193 | if #available(iOS 18.0, *), UIDevice.current.userInterfaceIdiom == .pad { 194 | sheetBody 195 | .frame(minWidth: minimumFittingSize.width, minHeight: minimumFittingSize.height) 196 | .presentationSizing(.fitted.sticky(horizontal: true, vertical: false)) 197 | } else { 198 | sheetBody 199 | } 200 | } 201 | } 202 | 203 | var sheetBody : some View { 204 | Group { 205 | switch style { 206 | case .alert: 207 | AdaptiveAlertView( 208 | isPresented: $isPresented, 209 | selectedDetent: $selectedDetent, 210 | adaptiveDetent: $adaptiveDetent, 211 | adaptiveDetentTwo: $adaptiveDetentTwo, 212 | heightLimit: trueHeightLimit, 213 | cardContent: cardContent 214 | ) 215 | case .scrollView: 216 | AdaptiveScrollView( 217 | isPresented: $isPresented, 218 | selectedDetent: $selectedDetent, 219 | adaptiveDetent: $adaptiveDetent, 220 | adaptiveDetentTwo: $adaptiveDetentTwo, 221 | isExpandable: $isExpandable, 222 | heightLimit: trueHeightLimit, 223 | cardContent: cardContent 224 | ) 225 | .background { 226 | if isMacEnvironment { 227 | EmptyView() 228 | } else { 229 | BackgroundFill(isLargeSheet: isLargeSheet) 230 | } 231 | } 232 | .overlay(alignment: .bottom, content: { 233 | bottomPinnedContent($isPresented, $selectedDetent) 234 | .background { PinnedGradientView(isLargeSheet: isLargeSheet) } 235 | .ignoresSafeArea(edges: .bottom) 236 | }) 237 | 238 | case .navigationScrollView: 239 | AdaptiveNavigationView( 240 | isPresented: $isPresented, 241 | selectedDetent: $selectedDetent, 242 | adaptiveDetent: $adaptiveDetent, 243 | adaptiveDetentTwo: $adaptiveDetentTwo, 244 | isExpandable: $isExpandable, 245 | heightLimit: trueHeightLimit, 246 | cardContent: cardContent 247 | ) 248 | .overlay(alignment: .bottom, content: { 249 | bottomPinnedContent($isPresented, $selectedDetent) 250 | .background { PinnedGradientView(isLargeSheet: isLargeSheet) } 251 | .ignoresSafeArea(edges: .bottom) 252 | }) 253 | .background { 254 | if isMacEnvironment { 255 | EmptyView() 256 | } else { 257 | BackgroundFill(isLargeSheet: isLargeSheet) 258 | } 259 | } 260 | 261 | case .navigationListView: 262 | 263 | if #available(iOS 18.0, *) { 264 | AdaptiveNavigationListView( 265 | isPresented: $isPresented, 266 | selectedDetent: $selectedDetent, 267 | adaptiveDetent: $adaptiveDetent, 268 | adaptiveDetentTwo: $adaptiveDetentTwo, 269 | isExpandable: $isExpandable, 270 | heightLimit: trueHeightLimit, 271 | cardContent: cardContent 272 | ) 273 | .overlay(alignment: .bottom, content: { 274 | bottomPinnedContent($isPresented, $selectedDetent) 275 | .background { PinnedGradientView(isLargeSheet: isLargeSheet) } 276 | .ignoresSafeArea(edges: .bottom) 277 | }) 278 | } else { 279 | // Fallback on earlier versions 280 | Text("Adaptive List View requires iOS 18") 281 | } 282 | } 283 | } 284 | .tint(.primary) 285 | .mask(BackgroundFill(isLargeSheet: isLargeSheet)) 286 | .padding(.horizontal, (isMacEnvironment || isLargeSheet) ? 0 : 16) 287 | .scrollBounceBehavior(.basedOnSize) 288 | .scrollIndicators(isLargeSheet ? .automatic : .hidden) 289 | .presentationDragIndicator(.hidden) 290 | .presentationDetents(availableDetents, selection: $selectedDetent) 291 | .presentationCompactAdaptation(.sheet) 292 | .presentationBackground { 293 | isMacEnvironment ? Color.backgroundColor : Color.clear 294 | } 295 | .interactiveDismissDisabled(!dismissEnabled) 296 | .animation(.default, value: selectedDetent) 297 | .animation(.default, value: adaptiveDetent) 298 | .animation(.default, value: adaptiveDetentTwo) 299 | .onChange(of: isLargeSheet) { oldValue, newValue in 300 | fullHeightDidChange(newValue) 301 | } 302 | } 303 | } 304 | 305 | extension Color { 306 | static var backgroundColor : Color { 307 | Color(uiColor: .systemGroupedBackground) 308 | } 309 | } 310 | 311 | struct AdaptiveAlertView : View { 312 | 313 | @Binding var isPresented : Bool 314 | @Binding var selectedDetent : PresentationDetent 315 | @Binding var adaptiveDetent : PresentationDetent 316 | @Binding var adaptiveDetentTwo : PresentationDetent 317 | @State private var isExpandable: Bool = false 318 | 319 | var heightLimit : CGFloat 320 | var cardContent: (Binding, Binding) -> CardContent 321 | 322 | private var isLargeSheet : Bool { selectedDetent == .large } 323 | 324 | var body: some View { 325 | ScrollView { 326 | cardContent($isPresented, $selectedDetent) 327 | .frame(maxWidth: .infinity) 328 | .background { 329 | if isMacEnvironment { 330 | EmptyView() 331 | } else { 332 | BackgroundFill(isLargeSheet: isLargeSheet) 333 | } 334 | } 335 | .onGeometryChange(for: CGFloat.self, of: \.size.height) { 336 | AdaptiveLayout.handleHeightChange( 337 | to: $0, 338 | selectedDetent: $selectedDetent, 339 | adaptiveDetent: $adaptiveDetent, 340 | adaptiveDetentTwo: $adaptiveDetentTwo, 341 | isExpandable: $isExpandable, 342 | heightLimit: heightLimit 343 | ) 344 | } 345 | .ignoresSafeArea(edges: .bottom) 346 | } 347 | } 348 | } 349 | 350 | struct AdaptiveScrollView : View { 351 | 352 | @Binding var isPresented : Bool 353 | @Binding var selectedDetent : PresentationDetent 354 | @Binding var adaptiveDetent : PresentationDetent 355 | @Binding var adaptiveDetentTwo : PresentationDetent 356 | 357 | @Binding var isExpandable: Bool 358 | var heightLimit : CGFloat 359 | var cardContent: (Binding, Binding) -> CardContent 360 | 361 | private var isLargeSheet : Bool { selectedDetent == .large } 362 | 363 | var body: some View { 364 | ScrollView { 365 | cardContent($isPresented, $selectedDetent) 366 | .frame(maxWidth: .infinity) 367 | .onGeometryChange(for: CGFloat.self, of: \.size.height) { 368 | AdaptiveLayout.handleHeightChange( 369 | to: $0, 370 | selectedDetent: $selectedDetent, 371 | adaptiveDetent: $adaptiveDetent, 372 | adaptiveDetentTwo: $adaptiveDetentTwo, 373 | isExpandable: $isExpandable, 374 | heightLimit: heightLimit 375 | ) } 376 | .ignoresSafeArea(edges: .bottom) 377 | } 378 | } 379 | } 380 | 381 | struct AdaptiveNavigationView : View { 382 | 383 | @Binding var isPresented : Bool 384 | @Binding var selectedDetent : PresentationDetent 385 | @Binding var adaptiveDetent : PresentationDetent 386 | @Binding var adaptiveDetentTwo : PresentationDetent 387 | 388 | @Binding var isExpandable: Bool 389 | var heightLimit : CGFloat 390 | var cardContent: (Binding, Binding) -> CardContent 391 | 392 | private var isLargeSheet : Bool { selectedDetent == .large } 393 | 394 | var body: some View { 395 | NavigationStack { 396 | ScrollView { 397 | cardContent($isPresented, $selectedDetent) 398 | .frame(maxWidth: .infinity) 399 | .onGeometryChange(for: CGFloat.self, of: \.size.height) { newValue in 400 | AdaptiveLayout.handleHeightChange( 401 | to: heightLimit + 1, 402 | selectedDetent: $selectedDetent, 403 | adaptiveDetent: $adaptiveDetent, 404 | adaptiveDetentTwo: $adaptiveDetentTwo, 405 | isExpandable: $isExpandable, 406 | heightLimit: heightLimit 407 | ) } 408 | .ignoresSafeArea(edges: .bottom) 409 | } 410 | } 411 | .frame(minHeight: heightLimit) 412 | } 413 | } 414 | 415 | @available(iOS 18.0, *) 416 | struct AdaptiveNavigationListView : View { 417 | 418 | @Binding var isPresented : Bool 419 | @Binding var selectedDetent : PresentationDetent 420 | @Binding var adaptiveDetent : PresentationDetent 421 | @Binding var adaptiveDetentTwo : PresentationDetent 422 | @Binding var isExpandable: Bool 423 | var heightLimit : CGFloat 424 | var cardContent: (Binding, Binding) -> CardContent 425 | 426 | private var isLargeSheet : Bool { selectedDetent == .large } 427 | 428 | var body: some View { 429 | NavigationStack { 430 | AdaptiveListView( 431 | isPresented: $isPresented, 432 | selectedDetent: $selectedDetent, 433 | adaptiveDetent: $adaptiveDetent, 434 | adaptiveDetentTwo: $adaptiveDetentTwo, 435 | isExpandable: $isExpandable, 436 | heightLimit: heightLimit, 437 | cardContent: cardContent 438 | ) 439 | .navigationBarTitleDisplayMode(.inline) 440 | } 441 | .background { Color.backgroundColor.ignoresSafeArea() } 442 | } 443 | } 444 | 445 | 446 | @available(iOS 18.0, *) 447 | struct AdaptiveListView: View { 448 | 449 | @Binding var isPresented : Bool 450 | @Binding var selectedDetent : PresentationDetent 451 | @Binding var adaptiveDetent : PresentationDetent 452 | @Binding var adaptiveDetentTwo : PresentationDetent 453 | @Binding var isExpandable: Bool 454 | var heightLimit : CGFloat 455 | var cardContent: (Binding, Binding) -> CardContent 456 | 457 | private var isLargeSheet : Bool { selectedDetent == .large } 458 | 459 | var body: some View { 460 | List { 461 | cardContent($isPresented, $selectedDetent) 462 | } 463 | .onScrollGeometryChange(for: CGFloat.self, of: \.contentSize.height) { oldValue, newValue in 464 | AdaptiveLayout.handleHeightChange( 465 | to: heightLimit + 1, 466 | selectedDetent: $selectedDetent, 467 | adaptiveDetent: $adaptiveDetent, 468 | adaptiveDetentTwo: $adaptiveDetentTwo, 469 | isExpandable: $isExpandable, 470 | heightLimit: heightLimit 471 | ) 472 | } 473 | .ignoresSafeArea(edges: .bottom) 474 | } 475 | } 476 | 477 | struct PinnedGradientView: View { 478 | 479 | var isLargeSheet : Bool 480 | 481 | var body: some View { 482 | LinearGradient( 483 | colors: [.backgroundColor.opacity(0), .backgroundColor], 484 | startPoint: .top, 485 | endPoint: .bottom 486 | ) 487 | .ignoresSafeArea(.all, edges: isLargeSheet ? [.bottom] : []) 488 | } 489 | } 490 | 491 | struct BackgroundFill : View { 492 | var isLargeSheet : Bool 493 | var body: some View { 494 | if isMacEnvironment { 495 | Rectangle() 496 | .foregroundStyle(Color.backgroundColor) 497 | .ignoresSafeArea(.all, edges: isLargeSheet ? [.bottom] : []) 498 | } else { 499 | RoundedRectangle(cornerRadius: isLargeSheet || isMacEnvironment ? 0 : 36) 500 | .foregroundStyle(Color.backgroundColor) 501 | .ignoresSafeArea(.all, edges: isLargeSheet ? [.bottom] : []) 502 | } 503 | } 504 | } 505 | 506 | 507 | 508 | struct AdaptiveLayout { 509 | 510 | @MainActor 511 | static func handleHeightChange( 512 | to height: CGFloat, 513 | selectedDetent : Binding, 514 | adaptiveDetent: Binding, 515 | adaptiveDetentTwo: Binding, 516 | isExpandable: Binding = .constant(false), 517 | heightLimit: CGFloat 518 | ) { 519 | var isLargeSheet : Bool { selectedDetent.wrappedValue == .large } 520 | 521 | isExpandable.wrappedValue = height > heightLimit 522 | 523 | withAnimation { 524 | if selectedDetent.wrappedValue == adaptiveDetent.wrappedValue { 525 | adaptiveDetentTwo.wrappedValue = .height(min(heightLimit, height)) 526 | if !isLargeSheet { selectedDetent.wrappedValue = adaptiveDetentTwo.wrappedValue } 527 | Task { 528 | try? await Task.sleep(nanoseconds: 100_000_000) // 1/10s 529 | adaptiveDetent.wrappedValue = .height(min(heightLimit, height - 1)) 530 | } 531 | } else { 532 | adaptiveDetent.wrappedValue = .height(min(heightLimit, height)) 533 | if !isLargeSheet { selectedDetent.wrappedValue = adaptiveDetent.wrappedValue } 534 | Task { 535 | try? await Task.sleep(nanoseconds: 100_000_000) // 1/10s 536 | adaptiveDetentTwo.wrappedValue = .height(min(heightLimit, height - 1)) 537 | } 538 | } 539 | } 540 | } 541 | } 542 | -------------------------------------------------------------------------------- /Sources/AdaptiveSheet/AdaptiveSheetPreview.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIView.swift 3 | // AdaptiveSheet 4 | // 5 | // Created by Ben McCarthy on 09/12/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct AdaptiveSheetPreview: View { 11 | 12 | public init() {} 13 | 14 | @State var isShowingSimpleAlert : Bool = false 15 | 16 | @State var isShowingList : Bool = false 17 | 18 | @State var expandingListCount : Int = 6 19 | @State var isShowingExpandingList : Bool = false 20 | 21 | @State var isShowingDemoNavView : Bool = false 22 | @State var isShowingDemoNavList : Bool = false 23 | 24 | @State var isShowingTest : Bool = false 25 | 26 | public var body: some View { 27 | Group { 28 | if #available(iOS 18.0, *) { 29 | DemoButtons( 30 | isShowingSimpleAlert: $isShowingSimpleAlert, 31 | isShowingList: $isShowingList, 32 | isShowingExpandingList: $isShowingExpandingList, 33 | isShowingDemoNavView: $isShowingDemoNavView, 34 | isShowingDemoNavList: $isShowingDemoNavList 35 | ) 36 | .adaptiveNavigationListSheet(isPresented: $isShowingDemoNavList) { _, _ in 37 | DemoNavigationList() 38 | .navigationTitle("Adaptive Demo") 39 | } 40 | } else { 41 | DemoButtons( 42 | isShowingSimpleAlert: $isShowingSimpleAlert, 43 | isShowingList: $isShowingList, 44 | isShowingExpandingList: $isShowingExpandingList, 45 | isShowingDemoNavView: $isShowingDemoNavView, 46 | isShowingDemoNavList: $isShowingDemoNavList 47 | ) 48 | } 49 | } 50 | .frame(maxWidth: 600) 51 | .adaptiveAlert(isPresented: $isShowingSimpleAlert) { isPresented, detent in 52 | DemoAlertView() 53 | } 54 | .adaptiveSheet(isPresented: $isShowingList, options: AdaptiveOptions(adaptiveDetentLimit: 300)) { _, _ in 55 | DemoListView() 56 | } bottomPinnedContent: { isPresented, _ in 57 | SheetButton(title: "Close") { 58 | isPresented.wrappedValue = false 59 | } 60 | } 61 | .adaptiveSheet(isPresented: $isShowingExpandingList, options: AdaptiveOptions(adaptiveDetentLimit: 500, minimumFittingSize: CGSize(width: 500, height: 200))) { _, _ in 62 | DemoExpandingListView(count: $expandingListCount) 63 | .safeAreaPadding(.bottom, 120) 64 | } bottomPinnedContent: { isPresented, _ in 65 | SheetButton(title: "Add Item") { 66 | expandingListCount += 1 67 | } 68 | } onDismiss: { 69 | expandingListCount = 0 70 | } 71 | 72 | .adaptiveNavigationSheet(isPresented: $isShowingDemoNavView) { _, _ in 73 | DemoNavigationView() 74 | } bottomPinnedContent: { isPresented, _ in 75 | SheetButton(title: "Close") { 76 | isPresented.wrappedValue = false 77 | } 78 | } 79 | } 80 | 81 | struct DemoButtons : View { 82 | 83 | @Binding var isShowingSimpleAlert : Bool 84 | @Binding var isShowingList : Bool 85 | @Binding var isShowingExpandingList : Bool 86 | @Binding var isShowingDemoNavView : Bool 87 | @Binding var isShowingDemoNavList : Bool 88 | 89 | var body: some View { 90 | VStack(spacing: 24) { 91 | DemoButton( 92 | isPresented: $isShowingSimpleAlert, 93 | title: "Show Alert", 94 | tint: .red 95 | ) 96 | 97 | DemoButton( 98 | isPresented: $isShowingList, 99 | title: "Show List", 100 | tint: .green 101 | ) 102 | 103 | DemoButton( 104 | isPresented: $isShowingExpandingList, 105 | title: "Show Expanding List", 106 | tint: .blue 107 | ) 108 | 109 | DemoButton( 110 | isPresented: $isShowingDemoNavView, 111 | title: "Show Nav View", 112 | tint: .indigo 113 | ) 114 | 115 | DemoButton( 116 | isPresented: $isShowingDemoNavList, 117 | title: "Show Nav List", 118 | tint: .purple 119 | ) 120 | } 121 | .padding(.horizontal, 48) 122 | } 123 | } 124 | 125 | struct DemoButton : View { 126 | 127 | @Binding var isPresented : Bool 128 | var title : String 129 | var tint : Color 130 | 131 | var body: some View { 132 | Button { 133 | isPresented = true 134 | } label: { 135 | Text(title) 136 | .font(.system(.headline, design: .default, weight: .semibold)) 137 | .frame(maxWidth: .infinity, minHeight: 32) 138 | } 139 | .buttonStyle(.borderedProminent) 140 | .tint(tint) 141 | } 142 | } 143 | 144 | struct SheetButton : View { 145 | 146 | var title : String 147 | var action : () -> () 148 | 149 | var body: some View { 150 | Button { 151 | action() 152 | } label: { 153 | Text(title) 154 | .font(.system(.headline, design: .default, weight: .semibold)) 155 | .foregroundStyle(Color(uiColor: .systemBackground)) 156 | .frame(maxWidth: .infinity, minHeight: 32) 157 | } 158 | .buttonStyle(.borderedProminent) 159 | .tint(.primary) 160 | .padding() 161 | } 162 | } 163 | 164 | struct DemoAlertView : View { 165 | var body: some View { 166 | Text("!") 167 | .font(.system(.title2, design: .default, weight: .bold)) 168 | .foregroundStyle(.red) 169 | .padding(.vertical, 24) 170 | } 171 | } 172 | 173 | struct DemoListView : View { 174 | 175 | let colors : [Color] = [.red, .orange, .yellow, .green, .teal, .blue, .indigo, .purple, .red, .orange, .yellow, .green, .teal, .blue, .indigo, .purple, .red, .orange, .yellow, .green, .teal, .blue, .indigo, .purple] 176 | 177 | var body: some View { 178 | VStack(spacing: 24) { 179 | ForEach(Array(colors.enumerated()), id: \.offset) { color in 180 | Image(systemName: "heart.fill") 181 | .foregroundStyle(color.element) 182 | } 183 | } 184 | .padding(.top) 185 | .safeAreaPadding(.bottom, 80) 186 | } 187 | } 188 | 189 | struct DemoExpandingListView : View { 190 | 191 | @Binding var count : Int 192 | let colors : [Color] = [.red, .orange, .yellow, .green, .teal, .blue, .indigo, .purple] 193 | 194 | var body: some View { 195 | VStack(spacing: 24) { 196 | ForEach(0...count, id: \.self) { index in 197 | Image(systemName: "heart.fill") 198 | .foregroundStyle(colors[index % colors.count]) 199 | } 200 | } 201 | .padding(.top) 202 | } 203 | } 204 | 205 | struct DemoNavigationView : View { 206 | var body: some View { 207 | HStack { 208 | NavigationLink { 209 | ZStack { 210 | Color.blue.opacity(0.15).ignoresSafeArea() 211 | 212 | Image(systemName: "fish.fill") 213 | .font(.system(.largeTitle, design: .default, weight: .bold)) 214 | .foregroundStyle(.blue) 215 | } 216 | } label: { 217 | Image(systemName: "fish.fill") 218 | .font(.system(.headline, design: .default, weight: .bold)) 219 | .frame(maxWidth: .infinity) 220 | .padding(.vertical, 6) 221 | } 222 | .buttonStyle(.bordered) 223 | .tint(.blue) 224 | 225 | NavigationLink { 226 | ZStack { 227 | Color.orange.opacity(0.15).ignoresSafeArea() 228 | 229 | Image(systemName: "carrot.fill") 230 | .font(.system(.largeTitle, design: .default, weight: .bold)) 231 | .foregroundStyle(.orange) 232 | } 233 | } label: { 234 | Image(systemName: "carrot.fill") 235 | .font(.system(.headline, design: .default, weight: .bold)) 236 | .frame(maxWidth: .infinity) 237 | .padding(.vertical, 6) 238 | } 239 | .buttonStyle(.bordered) 240 | .tint(.orange) 241 | 242 | NavigationLink { 243 | ZStack { 244 | Color.green.opacity(0.15).ignoresSafeArea() 245 | 246 | Image(systemName: "leaf.fill") 247 | .font(.system(.largeTitle, design: .default, weight: .bold)) 248 | .foregroundStyle(.green) 249 | } 250 | } label: { 251 | Image(systemName: "leaf.fill") 252 | .font(.system(.headline, design: .default, weight: .bold)) 253 | .frame(maxWidth: .infinity) 254 | .padding(.vertical, 6) 255 | } 256 | .buttonStyle(.bordered) 257 | .tint(.green) 258 | } 259 | .padding() 260 | .safeAreaPadding(.bottom, 60) 261 | } 262 | } 263 | 264 | struct DemoNavigationList : View { 265 | var body: some View { 266 | 267 | Section { 268 | NavigationLink { 269 | DemoNavigationListTwo() 270 | } label: { 271 | Text("One") 272 | } 273 | 274 | NavigationLink { 275 | Text("Page Two") 276 | } label: { 277 | Text("Two") 278 | } 279 | 280 | NavigationLink { 281 | Text("Page Three") 282 | } label: { 283 | Text("Three") 284 | } 285 | } 286 | } 287 | } 288 | 289 | struct DemoNavigationListTwo : View { 290 | var body: some View { 291 | List { 292 | Section { 293 | NavigationLink { Text("A") } label: { Text("A") } 294 | NavigationLink { Text("B") } label: { Text("B") } 295 | NavigationLink { Text("C") } label: { Text("C") } 296 | } 297 | 298 | Section { 299 | NavigationLink { Text("D") } label: { Text("D") } 300 | NavigationLink { Text("E") } label: { Text("E") } 301 | NavigationLink { Text("F") } label: { Text("F") } 302 | } 303 | 304 | Section { 305 | NavigationLink { Text("G") } label: { Text("G") } 306 | NavigationLink { Text("H") } label: { Text("H") } 307 | NavigationLink { Text("I") } label: { Text("I") } 308 | } 309 | } 310 | .listStyle(.insetGrouped) 311 | .navigationTitle("Page Two") 312 | } 313 | } 314 | } 315 | 316 | #Preview { 317 | AdaptiveSheetPreview() 318 | } 319 | --------------------------------------------------------------------------------