├── .swiftlint.yml ├── Sources └── BottomSheet │ ├── Media.xcassets │ ├── Contents.json │ └── xmark.circle.fill.imageset │ │ ├── xmark.circle.fill@2x.png │ │ ├── xmark.circle.fill@3x.png │ │ └── Contents.json │ ├── Helper │ ├── Extensions │ │ ├── ColorExtension.swift │ │ ├── UIApplicationExtension.swift │ │ ├── ViewExtension.swift │ │ └── BundleExtension.swift │ ├── AppleScrollBehaviourScrollView │ │ ├── DragGestureExtension.swift │ │ ├── TimerAnimation.swift │ │ └── UIScrollViewWrapper.swift │ ├── ViewModifiers │ │ ├── IgnoresSafeAreaCompatible.swift │ │ └── RoundedCornerViewModifier.swift │ └── Views │ │ └── VisualEffectView.swift │ ├── BottomSheet+ViewModifiers │ ├── BottomSheet+FloatingIPadSheet.swift │ ├── BottomSheet+SwipeToDismiss.swift │ ├── BottomSheet+Resizable.swift │ ├── BottomSheet+TapToDismiss.swift │ ├── BottomSheet+CustomAnimation.swift │ ├── BottomSheet+FlickThrough.swift │ ├── BottomSheet+CloseButton.swift │ ├── BottomSheet+OnDismiss.swift │ ├── BottomSheet+SheetWidth.swift │ ├── BottomSheet+ContentDrag.swift │ ├── BottomSheet+Threshold.swift │ ├── BottomSheet+AccountingForKeyboardHeight.swift │ ├── BottomSheet+BackgroundBlur.swift │ ├── BottomSheet+AppleScrollBehavior.swift │ ├── BottomSheet+DragIndicator.swift │ ├── BottomSheet+DragGesture.swift │ ├── BottomSheet+CustomBackground+iOS15.swift │ └── BottomSheet+CustomBackground.swift │ ├── BottomSheetView │ ├── BottomSheetView+KeyboardHeight.swift │ ├── BottomSheetView+Gestures.swift │ ├── BottomSheetView.swift │ ├── BottomSheetView+Calculations.swift │ ├── BottomSheetView+SwitchPosition.swift │ ├── BottomSheetView+DragIndicatorAction.swift │ └── BottomSheetView+HelperViews.swift │ ├── Models │ ├── BottomSheetWidth.swift │ ├── BottomSheetConfiguration.swift │ └── BottomSheetPosition.swift │ └── BottomSheet.swift ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── CI.yml ├── .gitattributes ├── LICENSE.txt ├── Package.swift ├── BottomSheetSwiftUI.podspec ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md └── README.md /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - trailing_whitespace 3 | -------------------------------------------------------------------------------- /Sources/BottomSheet/Media.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: lucaszischka 2 | patreon: lucaszischka 3 | open_collective: bottomsheet 4 | custom: "https://www.paypal.me/lucaszischka" 5 | -------------------------------------------------------------------------------- /Sources/BottomSheet/Media.xcassets/xmark.circle.fill.imageset/xmark.circle.fill@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucaszischka/BottomSheet/HEAD/Sources/BottomSheet/Media.xcassets/xmark.circle.fill.imageset/xmark.circle.fill@2x.png -------------------------------------------------------------------------------- /Sources/BottomSheet/Media.xcassets/xmark.circle.fill.imageset/xmark.circle.fill@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucaszischka/BottomSheet/HEAD/Sources/BottomSheet/Media.xcassets/xmark.circle.fill.imageset/xmark.circle.fill@3x.png -------------------------------------------------------------------------------- /Sources/BottomSheet/Helper/Extensions/ColorExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorExtension.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import SwiftUI 9 | 10 | internal extension Color { 11 | #if os(macOS) 12 | static var tertiaryLabel = Color(NSColor.tertiaryLabelColor) 13 | #else 14 | static var tertiaryLabel = Color(UIColor.tertiaryLabel) 15 | #endif 16 | } 17 | -------------------------------------------------------------------------------- /Sources/BottomSheet/Helper/AppleScrollBehaviourScrollView/DragGestureExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DragGestureExtension.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | #if !os(macOS) 9 | import SwiftUI 10 | 11 | internal extension DragGesture { 12 | enum DragState: Equatable { 13 | case none 14 | case changed(value: DragGesture.Value) 15 | case ended(value: DragGesture.Value) 16 | } 17 | } 18 | #endif 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Please file feature requests at the [ideas discussions page](https://github.com/lucaszischka/BottomSheet/discussions/categories/ideas). 11 | 12 | Of course, these are answered just as often and reliably, but offer a better surface to deal with them. Any issue that is actually a feature request will otherwise be turned into a discussion anyway. 13 | -------------------------------------------------------------------------------- /Sources/BottomSheet/Media.xcassets/xmark.circle.fill.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "xmark.circle.fill@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "xmark.circle.fill@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/BottomSheet/Helper/Extensions/UIApplicationExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIApplicationExtension.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | #if !os(macOS) 9 | import UIKit 10 | 11 | internal extension UIApplication { 12 | func endEditing() { 13 | sendAction( 14 | #selector(UIResponder.resignFirstResponder), 15 | to: nil, 16 | from: nil, 17 | for: nil 18 | ) 19 | } 20 | } 21 | #endif 22 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################### 2 | # Git Line Endings # 3 | ############################### 4 | 5 | # Set default behaviour to automatically normalize line endings. 6 | * text=auto 7 | 8 | # Force batch scripts to always use CRLF line endings so that if a repo is accessed 9 | # in Windows via a file share from Linux, the scripts will work. 10 | *.{cmd,[cC][mM][dD]} text eol=crlf 11 | *.{bat,[bB][aA][tT]} text eol=crlf 12 | 13 | # Force bash scripts to always use LF line endings so that if a repo is accessed 14 | # in Unix via a file share from Windows, the scripts will work. 15 | *.sh text eol=lf 16 | -------------------------------------------------------------------------------- /Sources/BottomSheet/Helper/Extensions/ViewExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewExtension.swift 3 | // 4 | // 5 | // Created by Jukka Hietanen on 12.12.2023. 6 | // 7 | 8 | import Foundation 9 | 10 | import SwiftUI 11 | import Combine 12 | 13 | internal extension View { 14 | /// A backwards compatible wrapper for iOS 14 `onChange` 15 | @ViewBuilder func valueChanged(value: T, onChange: @escaping (T) -> Void) -> some View { 16 | if #available(iOS 14.0, macOS 11.0, *) { 17 | self.onChange(of: value, perform: onChange) 18 | } else { 19 | self.onReceive(Just(value)) { (value) in 20 | onChange(value) 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/BottomSheet/BottomSheet+ViewModifiers/BottomSheet+FloatingIPadSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheet+FloatingIPadSheet.swift 3 | // 4 | // Created by Robin Pel. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension BottomSheet { 11 | 12 | /// Makes it possible to make the sheet appear like on iPhone. 13 | /// 14 | /// - Parameters: 15 | /// - bool: A boolean whether the option is enabled. 16 | /// 17 | /// - Returns: A BottomSheet that will actually appear at the bottom. 18 | func enableFloatingIPadSheet(_ bool: Bool = true) -> BottomSheet { 19 | self.configuration.iPadFloatingSheet = bool 20 | return self 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/BottomSheet/BottomSheet+ViewModifiers/BottomSheet+SwipeToDismiss.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheet+SwipeToDismiss.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension BottomSheet { 11 | 12 | /// Makes it possible to dismiss the BottomSheet by long swiping. 13 | /// 14 | /// - Parameters: 15 | /// - bool: A boolean whether the option is enabled. 16 | /// 17 | /// - Returns: A BottomSheet that can be dismissed by long swiping. 18 | func enableSwipeToDismiss(_ bool: Bool = true) -> BottomSheet { 19 | self.configuration.isSwipeToDismissEnabled = bool 20 | return self 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/BottomSheet/BottomSheet+ViewModifiers/BottomSheet+Resizable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheet+Resizable.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension BottomSheet { 11 | 12 | /// Makes it possible to resize the BottomSheet. 13 | /// 14 | /// When disabled the drag indicator disappears. 15 | /// 16 | /// - Parameters: 17 | /// - bool: A boolean whether the option is enabled. 18 | /// 19 | /// - Returns: A BottomSheet that can be resized. 20 | func isResizable(_ bool: Bool = true) -> BottomSheet { 21 | self.configuration.isResizable = bool 22 | return self 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/BottomSheet/BottomSheet+ViewModifiers/BottomSheet+TapToDismiss.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheet+TapToDismiss.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension BottomSheet { 11 | 12 | /// Makes it possible to dismiss the BottomSheet by tapping somewhere else. 13 | /// 14 | /// - Parameters: 15 | /// - bool: A boolean whether the option is enabled. 16 | /// 17 | /// - Returns: A BottomSheet that can be dismissed by tapping somewhere else. 18 | func enableTapToDismiss(_ bool: Bool = true) -> BottomSheet { 19 | self.configuration.isTapToDismissEnabled = bool 20 | return self 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/BottomSheet/BottomSheet+ViewModifiers/BottomSheet+CustomAnimation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheet+CustomAnimation.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public extension BottomSheet { 11 | 12 | /// Applies the given animation to the BottomSheet when any value changes. 13 | /// 14 | /// - Parameters: 15 | /// - animation: The animation to apply. If animation is nil, the view doesn’t animate. 16 | /// 17 | /// - Returns: A view that applies `animation` to the BottomSheet. 18 | func customAnimation(_ animation: Animation?) -> BottomSheet { 19 | self.configuration.animation = animation 20 | return self 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Minimal reproduce-able code** 14 | A short code snipped on which the issue can be reproduced. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Screenshots** 20 | If applicable, add screenshots to help explain your problem. 21 | 22 | **Target version** 23 | - Environment: [please select: iOS / iPad / macOS / macCatalyst] 24 | - Version: [the software version on which the issue appeared] 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /Sources/BottomSheet/BottomSheet+ViewModifiers/BottomSheet+FlickThrough.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheet+FlickThrough.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension BottomSheet { 11 | 12 | /// Makes it possible to switch directly to the top or bottom position by long swiping. 13 | /// 14 | /// - Parameters: 15 | /// - bool: A boolean whether the option is enabled. 16 | /// 17 | /// - Returns: A BottomSheet where long swiping to go directly to the top or bottom positions is enabled. 18 | func enableFlickThrough(_ bool: Bool = true) -> BottomSheet { 19 | self.configuration.isFlickThroughEnabled = bool 20 | return self 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/BottomSheet/BottomSheet+ViewModifiers/BottomSheet+CloseButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheet+CloseButton.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension BottomSheet { 11 | 12 | /// Adds a close button to the headerContent on the trailing side. 13 | /// 14 | /// To perform a custom action when the BottomSheet is closed (not only via the close button), 15 | /// please use the `.onDismiss()` option. 16 | /// 17 | /// - Parameters: 18 | /// - bool: A boolean whether the option is enabled. 19 | /// 20 | /// - Returns: A BottomSheet with a close button. 21 | func showCloseButton(_ bool: Bool = true) -> BottomSheet { 22 | self.configuration.isCloseButtonShown = bool 23 | return self 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/BottomSheet/BottomSheet+ViewModifiers/BottomSheet+OnDismiss.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheet+OnDismiss.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension BottomSheet { 11 | 12 | /// A action that will be performed when the BottomSheet is dismissed. 13 | /// 14 | /// Please note that when you dismiss the BottomSheet yourself, by setting the bottomSheetPosition to .hidden, 15 | /// the action will not be called. 16 | /// 17 | /// - Parameters: 18 | /// - perform: The action to perform when the BottomSheet is dismissed. 19 | /// 20 | /// - Returns: A BottomSheet with a custom on dismiss action. 21 | func onDismiss(_ perform: @escaping () -> Void) -> BottomSheet { 22 | self.configuration.onDismiss = perform 23 | return self 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/BottomSheet/BottomSheet+ViewModifiers/BottomSheet+SheetWidth.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheet+SheetWidth.swift 3 | // 4 | // Created by Robin Pel. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension BottomSheet { 11 | 12 | /// Makes it possible to configure a custom sheet width. 13 | /// 14 | /// Can be relative through `BottomSheetWidth.relative(x)`. 15 | /// Can be absolute through `BottomSheetWidth.absolute(x)`. 16 | /// Set to `BottomSheetWidth.platformDefault` to let the library decide the width. 17 | /// 18 | /// - Parameters: 19 | /// - width: The width to use for the bottom sheet. 20 | /// 21 | /// - Returns: A BottomSheet with the configured width. 22 | func sheetWidth(_ width: BottomSheetWidth = .platformDefault) -> BottomSheet { 23 | self.configuration.sheetWidth = width 24 | return self 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/BottomSheet/BottomSheet+ViewModifiers/BottomSheet+ContentDrag.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheet+ContentDrag.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension BottomSheet { 11 | 12 | /// Makes it possible to resize the BottomSheet by dragging the mainContent. 13 | /// 14 | /// Due to imitations in the SwiftUI framework, 15 | /// this option has no effect or even makes the BottomSheet glitch 16 | /// if the mainContent is packed into a ScrollView or a List. 17 | /// 18 | /// - Parameters: 19 | /// - bool: A boolean whether the option is enabled. 20 | /// 21 | /// - Returns: A BottomSheet where the mainContent can be used for resizing. 22 | func enableContentDrag(_ bool: Bool = true) -> BottomSheet { 23 | self.configuration.isContentDragEnabled = bool 24 | return self 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/BottomSheet/BottomSheet+ViewModifiers/BottomSheet+Threshold.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheet+Threshold.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension BottomSheet { 11 | 12 | /// Sets a custom threshold which determines, when to trigger swipe to dismiss or flick through. 13 | /// 14 | /// The threshold must be positive and higher than 10% (0.1). 15 | /// Changing the threshold does not affect whether either option is enabled. 16 | /// The default threshold is 30% (0.3) 17 | /// 18 | /// - Parameters: 19 | /// - threshold: The threshold as percentage of the screen height. 20 | /// 21 | /// - Returns: A BottomSheet with a custom threshold. 22 | func customThreshold(_ threshold: Double) -> BottomSheet { 23 | self.configuration.threshold = threshold 24 | return self 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | paths: 6 | - '.github/workflows/swiftlint.yml' 7 | - '.swiftlint.yml' 8 | - '**/*.swift' 9 | pull_request: 10 | paths: 11 | - '.github/workflows/swiftlint.yml' 12 | - '.swiftlint.yml' 13 | - '**/*.swift' 14 | 15 | jobs: 16 | SwiftLint: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | 23 | - name: GitHub Action for SwiftLint 24 | uses: norio-nomura/action-swiftlint@3.2.1 25 | 26 | Build: 27 | 28 | runs-on: macos-latest 29 | 30 | steps: 31 | - uses: actions/checkout@v3 32 | 33 | - name: Build  iOS 34 | run: xcodebuild -scheme BottomSheet -destination 'generic/platform=iOS' 35 | 36 | - name: Build  macOS 37 | run: xcodebuild -scheme BottomSheet -destination 'generic/platform=macOS' 38 | 39 | - name: Build  mac Catalyst 40 | run: xcodebuild -scheme BottomSheet -destination 'generic/platform=macOS,variant=Mac Catalyst' 41 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2022 Lucas Zischka 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 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: "BottomSheet", 8 | platforms: [ 9 | .iOS(.v13), 10 | .macCatalyst(.v13), 11 | .macOS(.v10_15) 12 | ], 13 | products: [ 14 | // Products define the executables and libraries a package produces, and make them visible to other packages. 15 | .library( 16 | name: "BottomSheet", 17 | targets: ["BottomSheet"]) 18 | ], 19 | dependencies: [ 20 | // Dependencies declare other packages that this package depends on. 21 | // .package(url: /* package url */, from: "1.0.0"), 22 | ], 23 | targets: [ 24 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 25 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 26 | .target( 27 | name: "BottomSheet", 28 | dependencies: []) 29 | ] 30 | ) 31 | -------------------------------------------------------------------------------- /Sources/BottomSheet/BottomSheet+ViewModifiers/BottomSheet+AccountingForKeyboardHeight.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheet+AccountingForKeyboardHeight.swift 3 | // 4 | // Created by Robin Pel. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | #if !os(macOS) 9 | import Foundation 10 | 11 | public extension BottomSheet { 12 | 13 | /// Adds padding to the bottom of the main content when the keyboard appears so all of the main content is visible. 14 | /// 15 | /// If the height of the sheet is smaller than the height of the keyboard, this modifier will not make the content visible. 16 | /// 17 | /// This modifier is not available on Mac, because it would not make sense there. 18 | /// 19 | /// - Parameters: 20 | /// - bool: A boolean whether the option is enabled. 21 | /// 22 | /// - Returns: A BottomSheet with its main content shifted up to account for the keyboard when it has appeared. 23 | func enableAccountingForKeyboardHeight(_ bool: Bool = true) -> BottomSheet { 24 | self.configuration.accountForKeyboardHeight = bool 25 | return self 26 | } 27 | } 28 | #endif 29 | -------------------------------------------------------------------------------- /Sources/BottomSheet/Helper/ViewModifiers/IgnoresSafeAreaCompatible.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IgnoresSafeAreaCompatible.swift 3 | // 4 | // 5 | // Created by Chocoford on 2023/6/16. 6 | // 7 | 8 | import SwiftUI 9 | 10 | internal enum SafeAreaRegionsCompatible { 11 | case all 12 | case container 13 | case keyboard 14 | 15 | @available(iOS 14.0, macOS 11.0, macCatalyst 14.0, *) 16 | var safeAreaRegions: SafeAreaRegions { 17 | switch self { 18 | case .all: 19 | return .all 20 | case .container: 21 | return .container 22 | case .keyboard: 23 | return .keyboard 24 | } 25 | } 26 | } 27 | 28 | internal extension View { 29 | @ViewBuilder 30 | func ignoresSafeAreaCompatible( 31 | _ regions: SafeAreaRegionsCompatible = .all, 32 | edges: Edge.Set = .all 33 | ) -> some View { 34 | if #available(iOS 14.0, macOS 11.0, *) { 35 | ignoresSafeArea( 36 | regions.safeAreaRegions, 37 | edges: edges 38 | ) 39 | } else { 40 | edgesIgnoringSafeArea(edges) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/BottomSheet/Helper/Extensions/BundleExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BundleExtension.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | #if !SWIFT_PACKAGE 9 | import Foundation 10 | 11 | private class BundleFinder {} 12 | 13 | internal extension Bundle { 14 | /// Returns the resource bundle associated with the current Swift module. 15 | static var module: Bundle = { 16 | let candidates = [ 17 | // Bundle should be present here when the package is linked into an App. 18 | Bundle.main.resourceURL, 19 | 20 | // Bundle should be present here when the package is linked into a framework. 21 | Bundle(for: BundleFinder.self).resourceURL, 22 | 23 | // For command-line tools. 24 | Bundle.main.bundleURL 25 | ] 26 | 27 | let bundleName = "BottomSheet_BottomSheet" 28 | 29 | for candidate in candidates { 30 | let bundlePath = candidate?.appendingPathComponent(bundleName + ".bundle") 31 | if let bundle = bundlePath.flatMap(Bundle.init(url:)) { 32 | return bundle 33 | } 34 | } 35 | 36 | // Return whatever bundle this code is in as a last resort. 37 | return Bundle(for: BundleFinder.self) 38 | }() 39 | } 40 | #endif 41 | -------------------------------------------------------------------------------- /Sources/BottomSheet/BottomSheetView/BottomSheetView+KeyboardHeight.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheetView+KeyboardHeight.swift 3 | // 4 | // 5 | // Created by Robin Pel on 06/09/2022. 6 | // 7 | 8 | #if !os(macOS) 9 | import UIKit 10 | import SwiftUI 11 | 12 | internal class KeyboardHeight: ObservableObject { 13 | 14 | @Published private(set) internal var value: CGFloat = 0 15 | 16 | internal init() { 17 | 18 | NotificationCenter.default.addObserver( 19 | self, 20 | selector: #selector(self.keyboardWillShow), 21 | name: UIResponder.keyboardWillShowNotification, 22 | object: nil 23 | ) 24 | 25 | NotificationCenter.default.addObserver( 26 | self, 27 | selector: #selector(self.keyboardWillHide), 28 | name: UIResponder.keyboardWillHideNotification, 29 | object: nil 30 | ) 31 | } 32 | 33 | @objc private func keyboardWillShow(_ notification: Notification) { 34 | if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue { 35 | DispatchQueue.main.async { 36 | self.value = keyboardFrame.cgRectValue.height 37 | } 38 | } 39 | } 40 | 41 | @objc private func keyboardWillHide(_ notification: Notification) { 42 | DispatchQueue.main.async { 43 | self.value = 0 44 | } 45 | } 46 | } 47 | #endif 48 | -------------------------------------------------------------------------------- /Sources/BottomSheet/BottomSheet+ViewModifiers/BottomSheet+BackgroundBlur.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheet+BackgroundBlur.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public extension BottomSheet { 11 | 12 | /// Adds a fullscreen blur layer below the BottomSheet. 13 | /// 14 | /// The opacity of the layer is proportional to the height of the BottomSheet. 15 | /// The material can be changed using the `.backgroundBlurMaterial()` modifier. 16 | /// 17 | /// - Parameters: 18 | /// - bool: A boolean whether the option is enabled. 19 | /// 20 | /// - Returns: A view that has a blur layer below the BottomSheet. 21 | func enableBackgroundBlur(_ bool: Bool = true) -> BottomSheet { 22 | self.configuration.isBackgroundBlurEnabled = bool 23 | return self 24 | } 25 | 26 | /// Changes the material of the blur layer. 27 | /// 28 | /// Changing the material does not affect whether the blur layer is shown. 29 | /// To toggle the blur layer please use the `.enableBackgroundBlur()` modifier. 30 | /// 31 | /// - Parameters: 32 | /// - material: The new material. 33 | /// 34 | /// - Returns: A view with a different material of the blur layer. 35 | func backgroundBlurMaterial(_ material: VisualEffect) -> BottomSheet { 36 | self.configuration.backgroundBlurMaterial = material 37 | return self 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/BottomSheet/BottomSheet+ViewModifiers/BottomSheet+AppleScrollBehavior.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheet+AppleScrollBehavior.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension BottomSheet { 11 | 12 | /// Packs the mainContent into a ScrollView. 13 | /// 14 | /// Behaviour on the iPhone: 15 | /// - The ScrollView is only enabled (scrollable) when the BottomSheet is in a `...Top` position. 16 | /// - If the offset of the ScrollView becomes less than or equal to 0, 17 | /// the BottomSheet is pulled down instead of scrolling. 18 | /// - In every other position the BottomSheet will be dragged instead 19 | /// 20 | /// This behaviour is not active on Mac and iPad, because it would not make sense there. 21 | /// 22 | /// Please note, that this feature has sometimes weird flickering, 23 | /// when the content of the ScrollView is smaller than itself. 24 | /// If you have experience with UIKit and UIScrollViews, you are welcome to open a pull request to fix this. 25 | /// 26 | /// - Parameters: 27 | /// - bool: A boolean whether the option is enabled. 28 | /// 29 | /// - Returns: A BottomSheet where the mainContent is packed inside a ScrollView. 30 | func enableAppleScrollBehavior(_ bool: Bool = true) -> BottomSheet { 31 | self.configuration.isAppleScrollBehaviorEnabled = bool 32 | return self 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/BottomSheet/Models/BottomSheetWidth.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheetWidth.swift 3 | // 4 | // Created by Robin Pel. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// `BottomSheetWidth` defines the possible BottomSheet widths that can be configured. 11 | /// 12 | /// Currently there are three options: 13 | /// - `.platformDefault`, which let's the library decide the width while taking the platform into account. 14 | /// - `.relative(CGFloat)`, which sets the width to a certain percentage of the available width. 15 | /// - `.absolute(CGFloat)`, which sets the width to the given amount. 16 | public enum BottomSheetWidth: Equatable { 17 | 18 | /// Apply the platform default width to the sheet. 19 | /// 20 | /// As from `BottomSheetView.platformDefaultWidth`: 21 | /// - Mac: 30% of the available width. 22 | /// - iPad: 30% of the available width. 23 | /// - iPhone landscape: 40% of the available width. 24 | /// - iPhone portrait: 100% of the available width. 25 | case platformDefault 26 | 27 | /// The width of the BottomSheet is equal to x% of the available width. 28 | /// Only values between 0 and 1 make sense. 29 | /// Instead of 0 please use `BottomSheetPosition.hidden`. 30 | case relative(CGFloat) 31 | 32 | /// The width of the BottomSheet is equal to x. 33 | /// Only values above 0 make sense. 34 | /// Instead of 0 please use `BottomSheetPosition.hidden`. 35 | case absolute(CGFloat) 36 | } 37 | -------------------------------------------------------------------------------- /BottomSheetSwiftUI.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |spec| 2 | spec.name = 'BottomSheetSwiftUI' 3 | spec.version = '3.1.1' 4 | spec.swift_version = '5.5' 5 | spec.authors = { 'Lucas Zischka' => 'lucas_zischka@outlook.com' } 6 | spec.license = { :type => 'MIT', :file => 'LICENSE.txt' } 7 | spec.homepage = 'https://github.com/lucaszischka/BottomSheet' 8 | spec.readme = 'https://github.com/lucaszischka/BottomSheet/blob/main/README.md' 9 | spec.changelog = 'https://github.com/lucaszischka/BottomSheet/blob/main/CHANGELOG.md' 10 | spec.source = { :git => 'https://github.com/lucaszischka/BottomSheet.git', 11 | :tag => spec.version.to_s } 12 | spec.summary = 'A sliding sheet from the bottom of the screen with custom states build with SwiftUI.' 13 | spec.screenshots = [ 'https://user-images.githubusercontent.com/63545066/132514316-c0d723c6-37fc-4104-b04c-6cf7bbcb0899.gif', 14 | 'https://user-images.githubusercontent.com/63545066/132514347-57c5397b-ec03-4716-8e01-4e693082e844.gif', 15 | 'https://user-images.githubusercontent.com/63545066/132514283-b14b2977-c5d1-4b49-96b1-19995cd5a41f.gif' ] 16 | 17 | spec.ios.deployment_target = '13.0' 18 | spec.osx.deployment_target = '10.15' 19 | 20 | spec.source_files = 'Sources/BottomSheet/**/*.swift' 21 | spec.resource_bundle = { 'BottomSheet_BottomSheet' => 'Sources/BottomSheet/**/*.xcassets' } 22 | 23 | end 24 | -------------------------------------------------------------------------------- /Sources/BottomSheet/BottomSheet+ViewModifiers/BottomSheet+DragIndicator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheet+DragIndicator.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public extension BottomSheet { 11 | 12 | /// Adds a drag indicator to the BottomSheet. 13 | /// 14 | /// On iPhone it is centered above the headerContent. 15 | /// On Mac and iPad it is centered above the mainContent, 16 | /// To change the color of the drag indicator please use the `.dragIndicatorColor()` modifier. 17 | /// 18 | /// - Parameters: 19 | /// - bool: A boolean whether the option is enabled. 20 | /// 21 | /// - Returns: A BottomSheet with a drag indicator. 22 | func showDragIndicator(_ bool: Bool = true) -> BottomSheet { 23 | self.configuration.isDragIndicatorShown = bool 24 | return self 25 | } 26 | 27 | /// Changes the color of the drag indicator. 28 | /// 29 | /// Changing the color does not affect whether the drag indicator is shown. 30 | /// To toggle the drag indicator please use the `.showDragIndicator()` modifier. 31 | /// 32 | /// - Parameters: 33 | /// - color: The new color. 34 | /// 35 | /// - Returns: A view with a different colored drag indicator. 36 | func dragIndicatorColor(_ color: Color) -> BottomSheet { 37 | self.configuration.dragIndicatorColor = color 38 | return self 39 | } 40 | 41 | /// Replaces the action that will be performed when the drag indicator is tapped. 42 | /// 43 | /// The `GeometryProxy` parameter can be used for calculations. 44 | /// You need to switch the positions and dismiss the keyboard yourself. 45 | /// The `GeometryProxy`'s height contains the bottom safe area inserts on iPhone. 46 | /// The `GeometryProxy`'s height contains the top safe area inserts on iPad and Mac. 47 | /// 48 | /// - Parameters: 49 | /// - perform: The action to perform when the drag indicator is tapped. 50 | /// 51 | /// - Returns: A BottomSheet with a custom on drag indicator action. 52 | func dragIndicatorAction(_ action: @escaping (GeometryProxy) -> Void) -> BottomSheet { 53 | self.configuration.dragIndicatorAction = action 54 | return self 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### macOS ### 2 | 3 | # General 4 | .DS_Store 5 | .AppleDouble 6 | .LSOverride 7 | 8 | # Icon must end with two \r 9 | Icon 10 | 11 | # Thumbnails 12 | ._* 13 | 14 | # Files that might appear in the root of a volume 15 | .DocumentRevisions-V100 16 | .fseventsd 17 | .Spotlight-V100 18 | .TemporaryItems 19 | .Trashes 20 | .VolumeIcon.icns 21 | .com.apple.timemachine.donotpresent 22 | 23 | # Directories potentially created on remote AFP share 24 | .AppleDB 25 | .AppleDesktop 26 | Network Trash Folder 27 | Temporary Items 28 | .apdisk 29 | 30 | 31 | ### Swift ### 32 | 33 | # Xcode 34 | 35 | ## User settings 36 | xcuserdata/ 37 | 38 | ## Obj-C/Swift specific 39 | *.hmap 40 | 41 | ## App packaging 42 | *.ipa 43 | *.dSYM.zip 44 | *.dSYM 45 | 46 | ## Playgrounds 47 | timeline.xctimeline 48 | playground.xcworkspace 49 | 50 | ## Gcc Patch 51 | /*.gcno 52 | 53 | ## Xcode Patch 54 | *.xcodeproj/* 55 | !*.xcodeproj/project.pbxproj 56 | !*.xcodeproj/xcshareddata/ 57 | !*.xcworkspace/contents.xcworkspacedata 58 | **/xcshareddata/WorkspaceSettings.xcsettings 59 | 60 | # Swift Package Manager 61 | 62 | ## Avoid checking in source code from Swift Package Manager dependencies. 63 | Packages/ 64 | Package.pins 65 | Package.resolved 66 | 67 | .swiftpm 68 | .build/ 69 | 70 | # CocoaPods 71 | 72 | Pods/ 73 | 74 | ## Avoid checking in source code from the Xcode workspace 75 | *.xcworkspace 76 | 77 | # Carthage 78 | 79 | ## Avoid checking in source code from Carthage dependencies. 80 | Carthage/Checkouts 81 | 82 | Carthage/Build/ 83 | 84 | # Accio dependency management 85 | 86 | Dependencies/ 87 | .accio/ 88 | 89 | # fastlane 90 | fastlane/report.xml 91 | fastlane/Preview.html 92 | fastlane/screenshots/**/*.png 93 | fastlane/test_output 94 | 95 | # Code Injection 96 | iOSInjectionProject/ 97 | 98 | 99 | ### Windows ### 100 | 101 | # Windows thumbnail cache files 102 | Thumbs.db 103 | Thumbs.db:encryptable 104 | ehthumbs.db 105 | ehthumbs_vista.db 106 | 107 | # Dump file 108 | *.stackdump 109 | 110 | # Folder config file 111 | [Dd]esktop.ini 112 | 113 | # Recycle Bin used on file shares 114 | $RECYCLE.BIN/ 115 | 116 | # Windows Installer files 117 | *.cab 118 | *.msi 119 | *.msix 120 | *.msm 121 | *.msp 122 | 123 | # Windows shortcuts 124 | *.lnk 125 | -------------------------------------------------------------------------------- /Sources/BottomSheet/Helper/AppleScrollBehaviourScrollView/TimerAnimation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimerAnimation.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | #if !os(macOS) 9 | import QuartzCore 10 | 11 | internal class TimerAnimation { 12 | 13 | typealias Animations = ( 14 | _ progress: Double, 15 | _ time: TimeInterval 16 | ) -> Void 17 | 18 | typealias Completion = (_ finished: Bool) -> Void 19 | 20 | init( 21 | duration: TimeInterval, 22 | animations: @escaping Animations, 23 | completion: Completion? = nil 24 | ) { 25 | self.duration = duration 26 | self.animations = animations 27 | self.completion = completion 28 | 29 | self.firstFrameTimestamp = CACurrentMediaTime() 30 | 31 | let displayLink = CADisplayLink( 32 | target: self, 33 | selector: #selector( 34 | self.handleFrame 35 | ) 36 | ) 37 | displayLink.add( 38 | to: .main, 39 | forMode: RunLoop.Mode.common 40 | ) 41 | self.displayLink = displayLink 42 | } 43 | 44 | deinit { 45 | self.invalidate() 46 | } 47 | 48 | func invalidate() { 49 | guard self.running else { 50 | return 51 | } 52 | self.running = false 53 | self.completion?(false) 54 | self.displayLink?.invalidate() 55 | } 56 | 57 | private let duration: TimeInterval 58 | private let animations: Animations 59 | private let completion: Completion? 60 | private weak var displayLink: CADisplayLink? 61 | 62 | private var running: Bool = true 63 | 64 | private let firstFrameTimestamp: CFTimeInterval 65 | 66 | @objc private func handleFrame(_ displayLink: CADisplayLink) { 67 | guard self.running else { 68 | return 69 | } 70 | let elapsed = CACurrentMediaTime() - self.firstFrameTimestamp 71 | if elapsed >= self.duration { 72 | self.animations( 73 | 1, 74 | self.duration 75 | ) 76 | self.running = false 77 | self.completion?(true) 78 | displayLink.invalidate() 79 | } else { 80 | self.animations( 81 | elapsed / self.duration, elapsed 82 | ) 83 | } 84 | } 85 | } 86 | #endif 87 | -------------------------------------------------------------------------------- /Sources/BottomSheet/BottomSheet+ViewModifiers/BottomSheet+DragGesture.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheet+DragGesture.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public extension BottomSheet { 11 | 12 | /// Adds an action to perform when the gesture’s value changes. 13 | /// 14 | /// - Parameters: 15 | /// - action: The action to perform when its gesture’s value changes. 16 | /// The `action` closure’s parameter contains the gesture’s new value. 17 | /// 18 | /// - Returns: The BottomSheet triggers `action` when its gesture’s value changes. 19 | func onDragChanged(_ perform: @escaping (DragGesture.Value) -> Void) -> BottomSheet { 20 | self.configuration.onDragChanged = perform 21 | return self 22 | } 23 | 24 | /// Adds an action to perform when the gesture ends. 25 | /// 26 | /// - Parameters: 27 | /// - action: The action to perform when its gesture ends. 28 | /// The `action` closure’s parameter contains the final value of the gesture. 29 | /// 30 | /// - Returns: The BottomSheet triggers `action` when its gesture ends. 31 | func onDragEnded(_ perform: @escaping (DragGesture.Value) -> Void) -> BottomSheet { 32 | self.configuration.onDragEnded = perform 33 | return self 34 | } 35 | 36 | /// Replaces the action that will be performed when the user drags the sheet down. 37 | /// 38 | /// The `GeometryProxy` and `DragGesture.Value` parameter can be used for calculations. 39 | /// You need to switch the positions, account for the reversed drag direction on iPad and Mac 40 | /// and dismiss the keyboard yourself. 41 | /// Also the `swipeToDismiss` and `flickThrough` features are triggered via this method. 42 | /// By replacing it, you will need to handle both yourself. 43 | /// The `GeometryProxy`'s height contains the bottom safe area inserts on iPhone. 44 | /// The `GeometryProxy`'s height contains the top safe area inserts on iPad and Mac. 45 | /// 46 | /// - Parameters: 47 | /// - perform: The action to perform when the drag indicator is tapped. 48 | /// 49 | /// - Returns: A BottomSheet with a custom on drag indicator action. 50 | func dragPositionSwitchAction(_ action: @escaping ( 51 | GeometryProxy, 52 | DragGesture.Value 53 | ) -> Void) -> BottomSheet { 54 | self.configuration.dragPositionSwitchAction = action 55 | return self 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/BottomSheet/Models/BottomSheetConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheetConfiguration.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import SwiftUI 9 | 10 | internal class BottomSheetConfiguration: Equatable { 11 | // For animating changes 12 | static func == ( 13 | lhs: BottomSheetConfiguration, 14 | rhs: BottomSheetConfiguration 15 | ) -> Bool { 16 | return lhs.animation == rhs.animation && 17 | lhs.backgroundBlurMaterial == rhs.backgroundBlurMaterial && 18 | lhs.backgroundViewID == rhs.backgroundViewID && 19 | lhs.dragIndicatorColor == rhs.dragIndicatorColor && 20 | lhs.isAppleScrollBehaviorEnabled == rhs.isAppleScrollBehaviorEnabled && 21 | lhs.isBackgroundBlurEnabled == rhs.isBackgroundBlurEnabled && 22 | lhs.isCloseButtonShown == rhs.isCloseButtonShown && 23 | lhs.isContentDragEnabled == rhs.isContentDragEnabled && 24 | lhs.isDragIndicatorShown == rhs.isDragIndicatorShown && 25 | lhs.isFlickThroughEnabled == rhs.isFlickThroughEnabled && 26 | lhs.isResizable == rhs.isResizable && 27 | lhs.isSwipeToDismissEnabled == rhs.isSwipeToDismissEnabled && 28 | lhs.isTapToDismissEnabled == rhs.isTapToDismissEnabled && 29 | lhs.iPadFloatingSheet == rhs.iPadFloatingSheet && 30 | lhs.sheetWidth == rhs.sheetWidth && 31 | lhs.accountForKeyboardHeight == rhs.accountForKeyboardHeight 32 | } 33 | 34 | var animation: Animation? = .spring( 35 | response: 0.5, 36 | dampingFraction: 0.75, 37 | blendDuration: 1 38 | ) 39 | var backgroundBlurMaterial: VisualEffect = .system 40 | var backgroundViewID: UUID? 41 | var backgroundView: AnyView? 42 | var dragIndicatorAction: ((GeometryProxy) -> Void)? 43 | var dragIndicatorColor: Color = Color.tertiaryLabel 44 | var dragPositionSwitchAction: (( 45 | GeometryProxy, 46 | DragGesture.Value 47 | ) -> Void)? 48 | var isAppleScrollBehaviorEnabled: Bool = false 49 | var isBackgroundBlurEnabled: Bool = false 50 | var isCloseButtonShown: Bool = false 51 | var isContentDragEnabled: Bool = false 52 | var isDragIndicatorShown: Bool = true 53 | var isFlickThroughEnabled: Bool = true 54 | var isResizable: Bool = true 55 | var isSwipeToDismissEnabled: Bool = false 56 | var isTapToDismissEnabled: Bool = false 57 | var onDismiss: () -> Void = {} 58 | var onDragEnded: (DragGesture.Value) -> Void = { _ in } 59 | var onDragChanged: (DragGesture.Value) -> Void = { _ in } 60 | var threshold: Double = 0.3 61 | var iPadFloatingSheet: Bool = true 62 | var sheetWidth: BottomSheetWidth = .platformDefault 63 | var accountForKeyboardHeight: Bool = false 64 | } 65 | -------------------------------------------------------------------------------- /Sources/BottomSheet/BottomSheetView/BottomSheetView+Gestures.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheetView+Gestures.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import SwiftUI 9 | 10 | internal extension BottomSheetView { 11 | func dragGesture(with geometry: GeometryProxy) -> some Gesture { 12 | DragGesture() 13 | .onChanged { value in 14 | self.lastDragValue = value 15 | 16 | // Perform custom onChanged action 17 | self.configuration.onDragChanged(value) 18 | 19 | // Update translation; on iPad floating and Mac the drag direction is reversed 20 | self.translation = self.isIPadFloatingOrMac ? -value.translation.height : value.translation.height 21 | // Dismiss the keyboard on drag 22 | self.endEditing() 23 | } 24 | // Set isDragging flag to true while user is dragging 25 | // The value is reset to false when dragging is stopped or cancelled 26 | .updating($isDragging) { (value, gestureState, transaction) in 27 | gestureState = true 28 | } 29 | } 30 | 31 | #if !os(macOS) 32 | func appleScrollViewDragGesture(with geometry: GeometryProxy) -> some Gesture { 33 | DragGesture() 34 | .onChanged { value in 35 | if self.bottomSheetPosition.isTop && value.translation.height < 0 { 36 | // Notify the ScrollView that the user is scrolling 37 | self.dragState = .changed(value: value) 38 | // Reset translation, because the user is scrolling 39 | self.translation = 0 40 | } else { 41 | // Perform custom action from the user 42 | self.configuration.onDragChanged(value) 43 | 44 | // Notify the ScrollView that the user is dragging 45 | self.dragState = .none 46 | // Update translation; on iPad floating and Mac the drag direction is reversed 47 | self.translation = self.isIPadFloatingOrMac ? -value.translation.height : value.translation.height 48 | } 49 | 50 | // Dismiss the keyboard on dragging/scrolling 51 | self.endEditing() 52 | } 53 | .onEnded { value in 54 | if value.translation.height < 0 && self.bottomSheetPosition.isTop { 55 | // Notify the ScrollView that the user ended scrolling via dragging 56 | self.dragState = .ended(value: value) 57 | 58 | // Reset translation, because the user ended scrolling via dragging 59 | self.translation = 0 60 | // Enable further interaction via the ScrollView directly 61 | self.isScrollEnabled = true 62 | } else { 63 | // Perform custom action from the user 64 | self.configuration.onDragEnded(value) 65 | 66 | // Notify the ScrollView that the user is dragging 67 | self.dragState = .none 68 | // Switch the position based on the translation and screen height 69 | self.dragPositionSwitch( 70 | with: geometry, 71 | value: value 72 | ) 73 | 74 | // Reset translation, because the dragging ended 75 | self.translation = 0 76 | } 77 | 78 | // Dismiss the keyboard after dragging/scrolling 79 | self.endEditing() 80 | } 81 | } 82 | #endif 83 | } 84 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | BottomSheet Changelog 2 | ================== 3 | 4 | #### v3.1.1 5 | - Fix #101 (thx @grandsir) 6 | - Fix #97 (#130) 7 | - Fix #119 (#120) 8 | - Fix #106 9 | 10 | #### v3.1.0 11 | - Added the `.enableAccountingForKeyboardHeight(Bool)` modifier #97 12 | - Added the `.enableFloatingIPadSheet(Bool)` modifier 13 | - Added the `.sheetWidth(BottomSheetWidth)` modifier 14 | - Fix #94 15 | 16 | #### v3.0.2 17 | - Added `.customThreshold(Double)` modifier #8, #88 18 | 19 | #### v3.0.1 20 | - Fix CocoaPods build #85 21 | - Fix close button not shown in dark mode #86 22 | 23 | #### v3.0.0 24 | - Recoded the project 25 | - Added iPhone landscape support 26 | - Added iPad support 27 | - Added MacOS support 28 | - Changed from options to view modifiers 29 | - Cleaned up the code (swiftLint and splitting it up) 30 | - Added dynamic size support 31 | - Added onDismiss modifier 32 | - Added dragIndicatorAction modifier 33 | - Added dragPositionSwitchAction modifier 34 | - Added dragGesture listener 35 | - Changed customBackground to not rely on AnyView 36 | - Fixed onAppear only called once #65 37 | 38 | #### v2.8.0 39 | - Add `disableBottomSafeAreaInsets` option #63 40 | - Add `disableFlickThrough` option #61 41 | 42 | #### v2.7.0 43 | - Fix drag indicator not draggable #45 44 | - Fix content not responding to tap gestures #51 45 | - Reworked `.appleScrollBehavior` to fix #46, #47 (also closes #53) 46 | - Redo explicit animation #59, #55 47 | 48 | #### v2.6.0 49 | - Fix critical bug with `.appleScrollBehavior` #40 50 | - Update code-style 51 | - Remove not used file 52 | - Update Readme 53 | - Renamed tapToDissmiss option tapToDismiss #43 54 | 55 | #### v2.5.0 56 | - Update Copyright 57 | - Update swift-tools-version 58 | - Update deprecated code (real fix for #19, replaces #20) 59 | - Add `.absolutePositionValue` option #37 60 | - Add `BottomSheetPositionAbsolute` 61 | - Use explicit animations #31 62 | - Hide examples in ReadMe 63 | - Update and fix `.appleScrollBehavior` #37 64 | - Code clean up 65 | 66 | #### v2.4.0 67 | - Add option to enable shadow 68 | - Add pod install 69 | 70 | #### v2.3.0 71 | - Fix compile for iOS 15 and Xcode 13 #19 #20 72 | - Add possibility to change the blur effect 73 | - Add possibility to change the corner radius 74 | 75 | #### v2.2.0 76 | - Add background option 77 | - Updated examples 78 | - File clean up 79 | 80 | #### v2.1.0 81 | - Add animation option (thx @deermichel) 82 | - Add appleScrollBehavior option 83 | - Add allowContentDrag option 84 | - Clean up code 85 | - Update Readme 86 | 87 | #### v2.0.0 88 | - Implementation of the "options" system 89 | - Add noDragIndicator option 90 | - Add swipeToDismiss option 91 | - Add tapToDissmiss option 92 | - Add backgroundBlur option 93 | - Add dragIndicatorColor(Color) option 94 | - Splitting the code in different files for better clarity (ViewExtension) 95 | - Reorganised Files 96 | - Design fixes 97 | - Update Documentation accordingly 98 | 99 | #### v1.0.7 100 | - Added flick through feature 101 | - Fixed drag indicator button not working issue #2 (thx @dbarsamian) 102 | - Improved Preview (thx @dbarsamian) 103 | - Plenty README.md updates 104 | - Fixed Dependencies 105 | - Custom States feature #4, #5 106 | - Extended SearchBar support 107 | 108 | #### v1.0.6 109 | - Updated .bottom BottomSheetPosition to mimic Apple Maps 110 | 111 | #### v1.0.5 112 | - Made the BottomSheet easier to drag 113 | - Updated .top BottomSheetPosition to mimic Apple Maps 114 | - Search Bar enhancements 115 | - Fixed .bottom BottomSheetPosition #1 116 | - Added Bottom Padding to the Title 117 | 118 | #### v1.0.4 119 | - Updated Transitions 120 | - Design updates 121 | 122 | #### v1.0.3 123 | - New Animation 124 | 125 | #### v1.0.2 126 | - Added Access control levels 127 | 128 | #### v1.0.1 129 | - Fix Animation 130 | - Readme Updates 131 | 132 | #### v1.0.0 133 | - Initial Release 134 | -------------------------------------------------------------------------------- /Sources/BottomSheet/Helper/ViewModifiers/RoundedCornerViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RoundedCornerViewModifier.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import SwiftUI 9 | 10 | #if os(macOS) 11 | internal struct UIRectCorner: OptionSet { 12 | 13 | let rawValue: Int 14 | 15 | static let topLeft = UIRectCorner(rawValue: 1 << 0) 16 | static let topRight = UIRectCorner(rawValue: 1 << 1) 17 | static let bottomRight = UIRectCorner(rawValue: 1 << 2) 18 | static let bottomLeft = UIRectCorner(rawValue: 1 << 3) 19 | 20 | static let allCorners: UIRectCorner = [ 21 | .topLeft, 22 | .topRight, 23 | .bottomLeft, 24 | .bottomRight 25 | ] 26 | } 27 | 28 | private struct RoundedCorner: Shape { 29 | 30 | var radius: CGFloat = .zero 31 | var corners: UIRectCorner = .allCorners 32 | 33 | // swiftlint:disable function_body_length 34 | func path(in rect: CGRect) -> Path { 35 | var path = Path() 36 | 37 | let point1 = CGPoint( 38 | x: rect.minX, 39 | y: self.corners.contains(.topLeft) ? rect.minY + self.radius : rect.minY 40 | ) 41 | let point2 = CGPoint( 42 | x: self.corners.contains(.topLeft) ? rect.minX + self.radius : rect.minX, 43 | y: rect.minY 44 | ) 45 | let point3 = CGPoint( 46 | x: self.corners.contains(.topRight) ? rect.maxX - self.radius : rect.maxX, 47 | y: rect.minY 48 | ) 49 | let point4 = CGPoint( 50 | x: rect.maxX, 51 | y: self.corners.contains(.topRight) ? rect.minY + self.radius : rect.minY 52 | ) 53 | let point5 = CGPoint( 54 | x: rect.maxX, 55 | y: self.corners.contains(.bottomRight) ? rect.maxY - self.radius : rect.maxY 56 | ) 57 | let point6 = CGPoint( 58 | x: self.corners.contains(.bottomRight) ? rect.maxX - self.radius : rect.maxX, 59 | y: rect.maxY 60 | ) 61 | let point7 = CGPoint( 62 | x: self.corners.contains(.bottomLeft) ? rect.minX + self.radius : rect.minX, 63 | y: rect.maxY 64 | ) 65 | let point8 = CGPoint( 66 | x: rect.minX, 67 | y: self.corners.contains(.bottomLeft) ? rect.maxY - self.radius : rect.maxY 68 | ) 69 | 70 | path.move(to: point1) 71 | path.addArc( 72 | tangent1End: CGPoint( 73 | x: rect.minX, 74 | y: rect.minY 75 | ), 76 | tangent2End: point2, 77 | radius: self.radius 78 | ) 79 | path.addLine(to: point3) 80 | path.addArc( 81 | tangent1End: CGPoint( 82 | x: rect.maxX, 83 | y: rect.minY 84 | ), 85 | tangent2End: point4, 86 | radius: self.radius 87 | ) 88 | path.addLine(to: point5) 89 | path.addArc( 90 | tangent1End: CGPoint( 91 | x: rect.maxX, 92 | y: rect.maxY 93 | ), 94 | tangent2End: point6, 95 | radius: self.radius 96 | ) 97 | path.addLine(to: point7) 98 | path.addArc( 99 | tangent1End: CGPoint( 100 | x: rect.minX, 101 | y: rect.maxY 102 | ), 103 | tangent2End: point8, 104 | radius: self.radius 105 | ) 106 | path.closeSubpath() 107 | 108 | return path 109 | } 110 | // swiftlint:enable function_body_length 111 | } 112 | #else 113 | private struct RoundedCorner: Shape { 114 | 115 | var radius: CGFloat = .infinity 116 | var corners: UIRectCorner = .allCorners 117 | 118 | func path(in rect: CGRect) -> Path { 119 | let path = UIBezierPath( 120 | roundedRect: rect, 121 | byRoundingCorners: self.corners, 122 | cornerRadii: CGSize( 123 | width: self.radius, 124 | height: self.radius 125 | ) 126 | ) 127 | return Path(path.cgPath) 128 | } 129 | } 130 | #endif 131 | 132 | internal extension View { 133 | func cornerRadius( 134 | _ radius: CGFloat, 135 | corners: UIRectCorner 136 | ) -> some View { 137 | clipShape(RoundedCorner( 138 | radius: radius, 139 | corners: corners 140 | )) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /Sources/BottomSheet/Models/BottomSheetPosition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheetPosition.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// `BottomSheetPosition` defines the possible BottomSheet states you can switch into. 11 | /// 12 | /// Currently there are three major types: 13 | /// - `.dynamic...`, where the height of the BottomSheet is equal to its content height 14 | /// - `.relative...`, where the height of the BottomSheet is a percentage of the screen height 15 | /// - `.absolute...`, where the height of the BottomSheet is a pixel value 16 | /// 17 | /// You can combine those types as much as you want. 18 | /// You can also use multiple instances of one case (for example `.relative(0.4)` and `.relative(0.6)`). 19 | public enum BottomSheetPosition: Equatable { 20 | /// The state where the BottomSheet is hidden. 21 | case hidden 22 | 23 | /// The state where only the headerContent is visible. 24 | case dynamicBottom 25 | 26 | /// The state where the height of the BottomSheet is equal to its content size. 27 | /// Only makes sense for views that don't take all available space (like ScrollVIew, Color, ...). 28 | case dynamic 29 | 30 | /// The state where the height of the BottomSheet is equal to its content size. 31 | /// It functions as top position for appleScrollBehaviour, 32 | /// although it doesn't make much sense to use it with dynamic. 33 | /// Only makes sense for views that don't take all available space (like ScrollVIew, Color, ...). 34 | case dynamicTop 35 | 36 | /// The state where only the headerContent is visible. 37 | /// The height of the BottomSheet is equal to x% of the available width. 38 | /// Only values between 0 and 1 make sense. 39 | /// Instead of 0 please use `.hidden`. 40 | case relativeBottom(CGFloat) 41 | 42 | /// The height of the BottomSheet is equal to x% of the available width. 43 | /// Only values between 0 and 1 make sense. 44 | /// Instead of 0 please use `.hidden`. 45 | case relative(CGFloat) 46 | 47 | /// The height of the BottomSheet is equal to x% of the available width. 48 | /// It functions as top position for appleScrollBehaviour. 49 | /// Only values between 0 and 1 make sense. 50 | /// Instead of 0 please use `.hidden`. 51 | case relativeTop(CGFloat) 52 | 53 | /// The state where only the headerContent is visible 54 | /// The height of the BottomSheet is x. 55 | /// Only values above 0 make sense. 56 | /// Instead of 0 please use `.hidden`. 57 | case absoluteBottom(CGFloat) 58 | 59 | /// The height of the BottomSheet is equal to x. 60 | /// Only values above 0 make sense. 61 | /// Instead of 0 please use `.hidden`. 62 | case absolute(CGFloat) 63 | 64 | /// The height of the BottomSheet is equal to x. 65 | /// It functions as top position for appleScrollBehaviour. 66 | /// Only values above 0 make sense. 67 | /// Instead of 0 please use `.hidden`. 68 | case absoluteTop(CGFloat) 69 | 70 | // State grouping 71 | internal var isHidden: Bool { 72 | switch self { 73 | case .hidden: 74 | return true 75 | default: 76 | return false 77 | } 78 | } 79 | 80 | internal var isBottom: Bool { 81 | switch self { 82 | case .dynamicBottom, .relativeBottom, .absoluteBottom: 83 | return true 84 | default: 85 | return false 86 | } 87 | } 88 | 89 | internal var isTop: Bool { 90 | switch self { 91 | case .dynamicTop, .relativeTop, .absoluteTop: 92 | return true 93 | default: 94 | return false 95 | } 96 | } 97 | 98 | internal var isDynamic: Bool { 99 | switch self { 100 | case .dynamicBottom, .dynamic, .dynamicTop: 101 | return true 102 | default: 103 | return false 104 | } 105 | } 106 | 107 | // Hight calculation 108 | internal func asScreenHeight(with maxBottomSheetHeight: CGFloat) -> CGFloat? { 109 | switch self { 110 | case .hidden: 111 | return 0 112 | case .dynamic, .dynamicTop, .dynamicBottom: 113 | return nil 114 | case .relative(let value), .relativeBottom(let value), .relativeTop(let value): 115 | return maxBottomSheetHeight * value 116 | case .absolute(let value), .absoluteBottom(let value), .absoluteTop(let value): 117 | return value 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Sources/BottomSheet/BottomSheetView/BottomSheetView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheetView.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import SwiftUI 9 | 10 | internal struct BottomSheetView: View { 11 | @GestureState var isDragging: Bool = false 12 | @State var lastDragValue: DragGesture.Value? 13 | 14 | // For iPhone landscape and iPad support 15 | #if !os(macOS) 16 | @Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass? 17 | @Environment(\.verticalSizeClass) var verticalSizeClass: UserInterfaceSizeClass? 18 | #endif 19 | 20 | @Binding var bottomSheetPosition: BottomSheetPosition 21 | @State var translation: CGFloat = 0 22 | 23 | #if !os(macOS) 24 | // For `appleScrollBehaviour` 25 | @State var isScrollEnabled: Bool = false 26 | @State var dragState: DragGesture.DragState = .none 27 | #endif 28 | 29 | // View heights 30 | @State var headerContentHeight: CGFloat = 0 31 | @State var dynamicMainContentHeight: CGFloat = 0 32 | 33 | #if !os(macOS) 34 | @ObservedObject var keyboardHeight: KeyboardHeight = KeyboardHeight() 35 | #endif 36 | 37 | // Views 38 | let headerContent: HContent? 39 | let mainContent: MContent 40 | 41 | let switchablePositions: [BottomSheetPosition] 42 | 43 | // Configuration 44 | let configuration: BottomSheetConfiguration 45 | 46 | var body: some View { 47 | // GeometryReader for size calculations 48 | GeometryReader { geometry in 49 | // ZStack for aligning content 50 | ZStack( 51 | // On iPad floating and Mac the BottomSheet is aligned to the top left 52 | // On iPhone and iPad not floating it is aligned to the bottom center, 53 | // in horizontal mode to the bottom left 54 | alignment: self.isIPadFloatingOrMac ? .topLeading : .bottomLeading 55 | ) { 56 | // Hide everything when the BottomSheet is hidden 57 | if !self.bottomSheetPosition.isHidden { 58 | // Full screen background for aligning and used by `backgroundBlur` and `tapToDismiss` 59 | self.fullScreenBackground(with: geometry) 60 | 61 | // The BottomSheet itself 62 | self.bottomSheet(with: geometry) 63 | } 64 | } 65 | // Handle drag ended or cancelled 66 | // Drag cancellation can happen e.g. when user drags from bottom of the screen to show app switcher 67 | .valueChanged(value: isDragging, onChange: { isDragging in 68 | if lastDragValue != nil && !isDragging { 69 | // Perform custom onEnded action 70 | self.configuration.onDragEnded(lastDragValue!) 71 | 72 | // Switch the position based on the translation and screen height 73 | self.dragPositionSwitch( 74 | with: geometry, 75 | value: lastDragValue! 76 | ) 77 | 78 | // Reset translation and last drag value, because the dragging ended 79 | self.translation = 0 80 | self.lastDragValue = nil 81 | // Dismiss the keyboard after drag 82 | self.endEditing() 83 | } 84 | }) 85 | // Animate value changes 86 | #if !os(macOS) 87 | .animation( 88 | self.configuration.animation, 89 | value: self.horizontalSizeClass 90 | ) 91 | .animation( 92 | self.configuration.animation, 93 | value: self.verticalSizeClass 94 | ) 95 | #endif 96 | .animation( 97 | self.configuration.animation, 98 | value: self.bottomSheetPosition 99 | ) 100 | .animation( 101 | self.configuration.animation, 102 | value: self.translation 103 | ) 104 | #if !os(macOS) 105 | .animation( 106 | self.configuration.animation, 107 | value: self.isScrollEnabled 108 | ) 109 | .animation( 110 | self.configuration.animation, 111 | value: self.dragState 112 | ) 113 | #endif 114 | .animation( 115 | self.configuration.animation, 116 | value: self.headerContentHeight 117 | ) 118 | .animation( 119 | self.configuration.animation, 120 | value: self.dynamicMainContentHeight 121 | ) 122 | .animation( 123 | self.configuration.animation, 124 | value: self.configuration 125 | ) 126 | } 127 | // Make the GeometryReader ignore specific safe area (for transition to work) 128 | // On iPhone and iPad not floating ignore bottom safe area, because the BottomSheet moves to the bottom edge 129 | // On iPad floating and Mac ignore top safe area, because the BottomSheet moves to the top edge 130 | .ignoresSafeAreaCompatible( 131 | .container, 132 | edges: self.isIPadFloatingOrMac ? .top : .bottom 133 | ) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /Sources/BottomSheet/BottomSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheet.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct BottomSheet: View { 11 | 12 | @Binding private var bottomSheetPosition: BottomSheetPosition 13 | 14 | // Views 15 | private let view: V 16 | private let headerContent: HContent? 17 | private let mainContent: MContent 18 | 19 | private let switchablePositions: [BottomSheetPosition] 20 | 21 | // Configuration 22 | internal let configuration: BottomSheetConfiguration = BottomSheetConfiguration() 23 | 24 | public var body: some View { 25 | // ZStack for creating the overlay on the original view 26 | ZStack { 27 | // The original view 28 | self.view 29 | 30 | BottomSheetView( 31 | bottomSheetPosition: self.$bottomSheetPosition, 32 | headerContent: self.headerContent, 33 | mainContent: self.mainContent, 34 | switchablePositions: self.switchablePositions, 35 | configuration: self.configuration 36 | ) 37 | } 38 | } 39 | 40 | // Initializers 41 | internal init( 42 | bottomSheetPosition: Binding, 43 | switchablePositions: [BottomSheetPosition], 44 | headerContent: HContent?, 45 | mainContent: MContent, 46 | view: V 47 | ) { 48 | self._bottomSheetPosition = bottomSheetPosition 49 | self.switchablePositions = switchablePositions 50 | self.headerContent = headerContent 51 | self.mainContent = mainContent 52 | self.view = view 53 | } 54 | 55 | internal init( 56 | bottomSheetPosition: Binding, 57 | switchablePositions: [BottomSheetPosition], 58 | title: String?, 59 | content: MContent, 60 | view: V 61 | ) { 62 | self.init( 63 | bottomSheetPosition: bottomSheetPosition, 64 | switchablePositions: switchablePositions, 65 | headerContent: { 66 | if let title = title { 67 | return Text(title) 68 | .font(.title) 69 | .bold() 70 | .lineLimit(1) 71 | as? HContent 72 | } else { 73 | return nil 74 | } 75 | }(), 76 | mainContent: content, 77 | view: view 78 | ) 79 | } 80 | } 81 | 82 | public extension View { 83 | 84 | /// Adds a BottomSheet to the view. 85 | /// 86 | /// - Parameter bottomSheetPosition: A binding that holds the current position/state of the BottomSheet. 87 | /// For more information about the possible positions see `BottomSheetPosition`. 88 | /// - Parameter switchablePositions: An array that contains the positions/states of the BottomSheet. 89 | /// Only the positions/states contained in the array can be switched into 90 | /// (via tapping the drag indicator or swiping the BottomSheet). 91 | /// - Parameter headerContent: A view that is used as header content for the BottomSheet. 92 | /// You can use a String that is displayed as title instead. 93 | /// - Parameter mainContent: A view that is used as main content for the BottomSheet. 94 | func bottomSheet( 95 | bottomSheetPosition: Binding, 96 | switchablePositions: [BottomSheetPosition], 97 | @ViewBuilder headerContent: () -> HContent? = { 98 | return nil 99 | }, 100 | @ViewBuilder mainContent: () -> MContent 101 | ) -> BottomSheet { 102 | BottomSheet( 103 | bottomSheetPosition: bottomSheetPosition, 104 | switchablePositions: switchablePositions, 105 | headerContent: headerContent(), 106 | mainContent: mainContent(), 107 | view: self 108 | ) 109 | } 110 | 111 | /// Adds a BottomSheet to the view. 112 | /// 113 | /// - Parameter bottomSheetPosition: A binding that holds the current position/state of the BottomSheet. 114 | /// For more information about the possible positions see `BottomSheetPosition`. 115 | /// - Parameter switchablePositions: An array that contains the positions/states for the BottomSheet. 116 | /// Only the positions/states contained in the array can be switched into 117 | /// (via tapping the drag indicator or swiping the BottomSheet). 118 | /// - Parameter title: A text that is displayed as title. 119 | /// You can use a view that is used as header content instead. 120 | /// - Parameter content: A view that is used as main content for the BottomSheet. 121 | typealias TitleContent = ModifiedContent> 122 | 123 | func bottomSheet( 124 | bottomSheetPosition: Binding, 125 | switchablePositions: [BottomSheetPosition], 126 | title: String? = nil, 127 | @ViewBuilder content: () -> MContent 128 | ) -> BottomSheet { 129 | BottomSheet( 130 | bottomSheetPosition: bottomSheetPosition, 131 | switchablePositions: switchablePositions, 132 | title: title, 133 | content: content(), 134 | view: self 135 | ) 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | lucas_zischka@outlook.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /Sources/BottomSheet/BottomSheet+ViewModifiers/BottomSheet+CustomBackground+iOS15.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheet+CustomBackground+iOS15.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @available(iOS 15, macOS 12, *) 11 | public extension BottomSheet { 12 | 13 | /// Sets the view's background to the default background style. 14 | /// 15 | /// This modifier behaves like ``View/background(_:ignoresSafeAreaEdges:)``, 16 | /// except that it always uses the ``ShapeStyle/background`` shape style. 17 | /// For example, you can add a background to a ``Label``: 18 | /// 19 | /// ZStack { 20 | /// Color.teal 21 | /// Label("Flag", systemImage: "flag.fill") 22 | /// .padding() 23 | /// .background() 24 | /// } 25 | /// 26 | /// Without the background modifier, the teal color behind the label shows 27 | /// through the label. With the modifier, the label's text and icon appear 28 | /// backed by a region filled with a color that's appropriate for light 29 | /// or dark appearance: 30 | /// 31 | /// ![A screenshot of a flag icon and the word flag inside a rectangle; the 32 | /// rectangle is filled with the background color and layered on top of a 33 | /// larger rectangle that's filled with the color teal.](View-background-7) 34 | /// 35 | /// If you want to specify a ``View`` or a stack of views as the background, 36 | /// use ``View/background(alignment:content:)`` instead. 37 | /// To specify a ``Shape`` or ``InsettableShape``, use 38 | /// ``View/background(_:in:fillStyle:)-89n7j`` or 39 | /// ``View/background(_:in:fillStyle:)-20tq5``, respectively. 40 | /// 41 | /// - Parameters: 42 | /// - edges: The set of edges for which to ignore safe area insets 43 | /// when adding the background. The default value is ``Edge/Set/all``. 44 | /// Specify an empty set to respect safe area insets on all edges. 45 | /// 46 | /// - Returns: A view with the ``ShapeStyle/background`` shape style 47 | /// drawn behind it. 48 | func customBackground(ignoresSafeAreaEdges edges: Edge.Set = .all) -> BottomSheet { 49 | return self.customBackground( 50 | .background, 51 | ignoresSafeAreaEdges: edges 52 | ) 53 | } 54 | 55 | /// Sets the view's background to a shape filled with the 56 | /// default background style. 57 | /// 58 | /// This modifier behaves like ``View/background(_:in:fillStyle:)-89n7j``, 59 | /// except that it always uses the ``ShapeStyle/background`` shape style 60 | /// to fill the specified shape. For example, you can create a ``Path`` 61 | /// that outlines a trapezoid: 62 | /// 63 | /// let trapezoid = Path { path in 64 | /// path.move(to: .zero) 65 | /// path.addLine(to: CGPoint(x: 90, y: 0)) 66 | /// path.addLine(to: CGPoint(x: 80, y: 50)) 67 | /// path.addLine(to: CGPoint(x: 10, y: 50)) 68 | /// } 69 | /// 70 | /// Then you can use that shape as a background for a ``Label``: 71 | /// 72 | /// ZStack { 73 | /// Color.teal 74 | /// Label("Flag", systemImage: "flag.fill") 75 | /// .padding() 76 | /// .background(in: trapezoid) 77 | /// } 78 | /// 79 | /// Without the background modifier, the fill color shows 80 | /// through the label. With the modifier, the label's text and icon appear 81 | /// backed by a shape filled with a color that's appropriate for light 82 | /// or dark appearance: 83 | /// 84 | /// ![A screenshot of a flag icon and the word flag inside a trapezoid; the 85 | /// trapezoid is filled with the background color and layered on top of 86 | /// a rectangle filled with the color teal.](View-background-B) 87 | /// 88 | /// To create a background with other ``View`` types --- or with a stack 89 | /// of views --- use ``View/background(alignment:content:)`` instead. 90 | /// To add a ``ShapeStyle`` as a background, use 91 | /// ``View/background(_:ignoresSafeAreaEdges:)``. 92 | /// 93 | /// - Parameters: 94 | /// - shape: An instance of a type that conforms to ``Shape`` that 95 | /// SwiftUI draws behind the view using the ``ShapeStyle/background`` 96 | /// shape style. 97 | /// - fillStyle: The ``FillStyle`` to use when drawing the shape. 98 | /// The default style uses the nonzero winding number rule and 99 | /// antialiasing. 100 | /// 101 | /// - Returns: A view with the specified shape drawn behind it. 102 | func customBackground( 103 | in shape: S, 104 | fillStyle: FillStyle = FillStyle() 105 | ) -> BottomSheet where S: Shape { 106 | return self.customBackground( 107 | .background, 108 | in: shape, 109 | fillStyle: fillStyle 110 | ) 111 | } 112 | 113 | /// Sets the view's background to an insettable shape filled with the 114 | /// default background style. 115 | /// 116 | /// This modifier behaves like ``View/background(_:in:fillStyle:)-20tq5``, 117 | /// except that it always uses the ``ShapeStyle/background`` shape style 118 | /// to fill the specified insettable shape. For example, you can use 119 | /// a ``RoundedRectangle`` as a background on a ``Label``: 120 | /// 121 | /// ZStack { 122 | /// Color.teal 123 | /// Label("Flag", systemImage: "flag.fill") 124 | /// .padding() 125 | /// .background(in: RoundedRectangle(cornerRadius: 8)) 126 | /// } 127 | /// 128 | /// Without the background modifier, the fill color shows 129 | /// through the label. With the modifier, the label's text and icon appear 130 | /// backed by a shape filled with a color that's appropriate for light 131 | /// or dark appearance: 132 | /// 133 | /// ![A screenshot of a flag icon and the word flag inside a rectangle with 134 | /// rounded corners; the rectangle is filled with the background color, and 135 | /// is layered on top of a larger rectangle that's filled with the color 136 | /// teal.](View-background-9) 137 | /// 138 | /// To create a background with other ``View`` types --- or with a stack 139 | /// of views --- use ``View/background(alignment:content:)`` instead. 140 | /// To add a ``ShapeStyle`` as a background, use 141 | /// ``View/background(_:ignoresSafeAreaEdges:)``. 142 | /// 143 | /// - Parameters: 144 | /// - shape: An instance of a type that conforms to ``InsettableShape`` 145 | /// that SwiftUI draws behind the view using the 146 | /// ``ShapeStyle/background`` shape style. 147 | /// - fillStyle: The ``FillStyle`` to use when drawing the shape. 148 | /// The default style uses the nonzero winding number rule and 149 | /// antialiasing. 150 | /// 151 | /// - Returns: A view with the specified insettable shape drawn behind it. 152 | func customBackground( 153 | in shape: S, 154 | fillStyle: FillStyle = FillStyle() 155 | ) -> BottomSheet where S: InsettableShape { 156 | return self.customBackground( 157 | .background, 158 | in: shape, 159 | fillStyle: fillStyle 160 | ) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /Sources/BottomSheet/BottomSheetView/BottomSheetView+Calculations.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheetView+Calculations.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import SwiftUI 9 | 10 | internal extension BottomSheetView { 11 | 12 | var isIPad: Bool { 13 | #if os(macOS) 14 | return false 15 | #else 16 | return self.horizontalSizeClass == .regular && self.verticalSizeClass == .regular 17 | #endif 18 | } 19 | 20 | var isMac: Bool { 21 | #if os(macOS) 22 | return true 23 | #else 24 | return false 25 | #endif 26 | } 27 | 28 | var isIPadOrMac: Bool { 29 | return self.isIPad || self.isMac 30 | } 31 | 32 | var isIPadFloating: Bool { 33 | return self.isIPad && self.configuration.iPadFloatingSheet 34 | } 35 | 36 | var isIPadFloatingOrMac: Bool { 37 | return self.isIPadFloating || self.isMac 38 | } 39 | 40 | var isIPadBottom: Bool { 41 | return self.isIPad && !self.configuration.iPadFloatingSheet 42 | } 43 | 44 | var topPadding: CGFloat { 45 | if self.isIPadFloatingOrMac { 46 | #if os(macOS) 47 | return NSApplication.shared.mainMenu?.menuBarHeight ?? 20 48 | #else 49 | return UIApplication.shared.windows.first?.safeAreaInsets.top ?? 10 50 | #endif 51 | } else { 52 | return 0 53 | } 54 | } 55 | 56 | // Whether the header is a title 57 | var isTitleAsHeaderContent: Bool { 58 | return self.headerContent is ModifiedContent> 59 | } 60 | 61 | // The height of the spacer when position is bottom 62 | var bottomPositionSpacerHeight: CGFloat? { 63 | // Only limit height when dynamic 64 | if self.bottomSheetPosition.isDynamic { 65 | // When dynamic return safe area and header height 66 | return self.bottomPositionSafeAreaHeight + self.headerContentHeight 67 | } else { 68 | // When not dynamic let it take all available space 69 | return nil 70 | } 71 | } 72 | 73 | // The height of the safe area when position is bottom 74 | var bottomPositionSafeAreaHeight: CGFloat { 75 | // Only add safe area when `dynamicBottom` and not on iPad floating or Mac 76 | if self.bottomSheetPosition == .dynamicBottom && !self.isIPadFloatingOrMac { 77 | #if !os(macOS) 78 | // Safe area as height (iPhone) 79 | return UIApplication.shared.windows.first?.safeAreaInsets.bottom ?? 20 80 | #else 81 | // Should never be called 82 | return 0 83 | #endif 84 | } else { 85 | // When not `.dynamicBottom` or when iPad floating or Mac don't add safe area 86 | return 0 87 | } 88 | } 89 | 90 | // The minimum height of the BottomSheet 91 | var minBottomSheetHeight: CGFloat { 92 | // Header content and drag indicator height 93 | return self.headerContentHeight + ( 94 | self.configuration.isResizable && self.configuration.isDragIndicatorShown ? 20 : 0 95 | ) 96 | } 97 | 98 | // The maximum height of the BottomSheet 99 | func maxBottomSheetHeight(with geometry: GeometryProxy) -> CGFloat { 100 | // Screen height without safe areas and padding 101 | return geometry.size.height - (self.isIPadFloatingOrMac ? 20 : 0) - self.topPadding 102 | } 103 | 104 | // The current height of the BottomSheet (without translation) 105 | func currentBottomSheetHeight(with geometry: GeometryProxy) -> CGFloat { 106 | switch self.bottomSheetPosition { 107 | case .hidden: 108 | return 0 109 | case .dynamic, .dynamicTop, .dynamicBottom: 110 | // Main content, header content and drag indicator height 111 | return self.dynamicMainContentHeight + self.headerContentHeight + ( 112 | self.configuration.isResizable && self.configuration.isDragIndicatorShown ? 20 : 0 113 | ) 114 | case .relative(let value), .relativeBottom(let value), .relativeTop(let value): 115 | // Percentage of the max height 116 | return self.maxBottomSheetHeight(with: geometry) * value 117 | case .absolute(let value), .absoluteBottom(let value), .absoluteTop(let value): 118 | return value 119 | } 120 | } 121 | 122 | // For iPad and Mac 123 | func maxMainContentHeight(with geometry: GeometryProxy) -> CGFloat? { 124 | if self.bottomSheetPosition.isDynamic && self.dynamicMainContentHeight < max( 125 | self.maxBottomSheetHeight(with: geometry) - self.translation - self.headerContentHeight - ( 126 | self.configuration.isResizable && self.configuration.isDragIndicatorShown ? 20 : 0 127 | ), 128 | 0 129 | ) { 130 | // Let dynamic content take all space it wants, as long as it is smaller than the allowed height 131 | return nil 132 | } else { 133 | // The max height of the main content is the height of the BottomSheet 134 | // without the header and drag indicator 135 | return max( 136 | self.height(with: geometry) - self.headerContentHeight - ( 137 | self.configuration.isResizable && self.configuration.isDragIndicatorShown ? 20 : 0 138 | ), 139 | 0 140 | ) 141 | } 142 | } 143 | 144 | // For `bottomSheetPosition` 145 | func height(with geometry: GeometryProxy) -> CGFloat { 146 | // Calculate BottomSheet height by subtracting translation 147 | return min( 148 | max( 149 | self.currentBottomSheetHeight(with: geometry) - self.translation, 150 | self.minBottomSheetHeight 151 | ), 152 | self.maxBottomSheetHeight(with: geometry) 153 | ) 154 | } 155 | 156 | // For iPhone landscape, iPad and Mac support 157 | func width(with geometry: GeometryProxy) -> CGFloat { 158 | switch self.configuration.sheetWidth { 159 | 160 | case .platformDefault: 161 | return self.platformDefaultWidth(with: geometry) 162 | 163 | case .relative(let width): 164 | // Don't allow the width to be smaller than zero, or larger than one 165 | return geometry.size.width * max( 166 | 0, 167 | min( 168 | 1, 169 | width 170 | ) 171 | ) 172 | 173 | case .absolute(let width): 174 | return max( 175 | 0, 176 | width 177 | ) 178 | } 179 | } 180 | 181 | func platformDefaultWidth(with geometry: GeometryProxy) -> CGFloat { 182 | #if os(macOS) 183 | // On Mac use 30% of the width 184 | return geometry.size.width * 0.3 185 | #else 186 | if self.isIPad { 187 | // On iPad use 30% of the width 188 | return geometry.size.width * 0.3 189 | } else if UIDevice.current.userInterfaceIdiom == .phone && UIDevice.current.orientation.isLandscape { 190 | // On iPhone landscape use 40% of the width 191 | return geometry.size.width * 0.4 192 | } else { 193 | // On iPhone portrait or iPad split screen use 100% of the width 194 | return geometry.size.width 195 | } 196 | #endif 197 | } 198 | 199 | // For `backgroundBlur` 200 | func opacity(with geometry: GeometryProxy) -> Double { 201 | if self.configuration.isBackgroundBlurEnabled { 202 | // Calculate background blur relative to BottomSheet height 203 | return min( 204 | max( 205 | Double( 206 | self.height(with: geometry) / self.maxBottomSheetHeight(with: geometry) 207 | ), 208 | 0 209 | ), 210 | 1 211 | ) 212 | } else { 213 | return 0 214 | } 215 | } 216 | 217 | // For `tapToDismiss` 218 | func tapToDismissAction() { 219 | // Only dismiss sheet when `tapToDismiss` is enabled 220 | if self.configuration.isTapToDismissEnabled { 221 | self.closeSheet() 222 | } 223 | } 224 | 225 | // For closing the sheet 226 | func closeSheet() { 227 | self.bottomSheetPosition = .hidden 228 | self.endEditing() 229 | 230 | self.configuration.onDismiss() 231 | } 232 | 233 | // For `endEditing` 234 | func endEditing() { 235 | #if !os(macOS) 236 | UIApplication.shared.endEditing() 237 | #endif 238 | } 239 | 240 | // For position switching 241 | func getSwitchablePositions(with geometry: GeometryProxy) -> [( 242 | height: CGFloat, 243 | position: BottomSheetPosition 244 | )] { 245 | return self.switchablePositions.compactMap({ (position) -> ( 246 | height: CGFloat, 247 | position: BottomSheetPosition 248 | )? in 249 | if let height = position.asScreenHeight(with: self.maxBottomSheetHeight(with: geometry)) { 250 | if position.isHidden || position == self.bottomSheetPosition { 251 | // Remove hidden and current position 252 | return nil 253 | } else { 254 | return ( 255 | height: height, 256 | position: position 257 | ) 258 | } 259 | } else { 260 | // Remove `.dynamic...` positions 261 | return nil 262 | } 263 | }).sorted(by: { 264 | // Sort by height (low to high) 265 | $0.height < $1.height 266 | }) 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /Sources/BottomSheet/Helper/Views/VisualEffectView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VisualEffectView.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import SwiftUI 9 | 10 | // Adapted from https://github.com/AlanQuatermain/AQUI/blob/master/Sources/AQUI/VisualEffectView.swift 11 | #if canImport(UIKit) 12 | import UIKit 13 | #elseif os(macOS) 14 | import AppKit 15 | #endif 16 | 17 | /// Describes a visual effect to be applied to the background of a view, typically to provide 18 | /// a blurred rendition of the content below the view in z-order. 19 | public enum VisualEffect: Equatable, Hashable { 20 | /// The material types available for the effect. 21 | /// 22 | /// On iOS this uses material types to specify the desired effect, while on 23 | /// macOS the materials are specified semantically based on their expected use case. 24 | public enum Material: Equatable, Hashable { 25 | /// A default appearance, suitable for most cases. 26 | case `default` 27 | 28 | /// A blur simulating a very thin material. 29 | @available(iOS 13.0, *) 30 | @available(macOS, unavailable) 31 | case ultraThin 32 | 33 | /// A blur simulating a thin material. 34 | @available(iOS 13.0, *) 35 | @available(macOS, unavailable) 36 | case thin 37 | 38 | /// A blur simulating a thicker than normal material. 39 | @available(iOS 13.0, *) 40 | @available(macOS, unavailable) 41 | case thick 42 | 43 | /// A blur matching the system chrome. 44 | @available(iOS 13.0, *) 45 | @available(macOS, unavailable) 46 | case chrome 47 | 48 | /// A material suitable for a window titlebar. 49 | @available(macOS 10.15, *) 50 | @available(iOS, unavailable) 51 | @available(macCatalyst, unavailable) 52 | case titlebar 53 | 54 | /// A material used for the background of a window. 55 | @available(macOS 10.15, *) 56 | @available(iOS, unavailable) 57 | @available(macCatalyst, unavailable) 58 | case windowBackground 59 | 60 | /// A material used for an inline header view. 61 | /// - Parameter behindWindow: `true` if the effect should use 62 | /// the content behind the window, `false` to use content within 63 | /// the window at a lower z-order. 64 | @available(macOS 10.15, *) 65 | @available(iOS, unavailable) 66 | @available(macCatalyst, unavailable) 67 | case headerView(behindWindow: Bool) 68 | 69 | /// A material used for the background of a content view, e.g. a scroll 70 | /// view or a list. 71 | /// - Parameter behindWindow: `true` if the effect should use 72 | /// the content behind the window, `false` to use content within 73 | /// the window at a lower z-order. 74 | @available(macOS 10.15, *) 75 | @available(iOS, unavailable) 76 | @available(macCatalyst, unavailable) 77 | case contentBackground(behindWindow: Bool) 78 | 79 | /// A material used for the background of a view that contains a 80 | /// 'page' interface, as in some document-based applications. 81 | /// - Parameter behindWindow: `true` if the effect should use 82 | /// the content behind the window, `false` to use content within 83 | /// the window at a lower z-order. 84 | @available(macOS 10.15, *) 85 | @available(iOS, unavailable) 86 | @available(macCatalyst, unavailable) 87 | case behindPageBackground(behindWindow: Bool) 88 | } 89 | 90 | /// A standard effect that adapts to the current `ColorScheme`. 91 | case system 92 | /// A standard effect that uses the system light appearance. 93 | case systemLight 94 | /// A standard effect that uses the system dark appearance. 95 | case systemDark 96 | 97 | /// An adaptive effect with the given material that changes to match 98 | /// the current `ColorScheme`. 99 | case adaptive(Material) 100 | /// An effect that uses the given material with the system light appearance. 101 | case light(Material) 102 | /// An effect that uses the given material with the system dark appearance. 103 | case dark(Material) 104 | } 105 | 106 | #if os(iOS) || targetEnvironment(macCatalyst) 107 | fileprivate extension VisualEffect { 108 | var blurStyle: UIBlurEffect.Style { 109 | switch self { 110 | case .system: return .systemMaterial 111 | case .systemLight: return .systemMaterialLight 112 | case .systemDark: return .systemMaterialDark 113 | case .adaptive(let material): 114 | switch material { 115 | case .ultraThin: return .systemUltraThinMaterial 116 | case .thin: return .systemThinMaterial 117 | case .default: return .systemMaterial 118 | case .thick: return .systemThickMaterial 119 | case .chrome: return .systemChromeMaterial 120 | } 121 | case .light(let material): 122 | switch material { 123 | case .ultraThin: return .systemUltraThinMaterialLight 124 | case .thin: return .systemThinMaterialLight 125 | case .default: return .systemMaterialLight 126 | case .thick: return .systemThickMaterialLight 127 | case .chrome: return .systemChromeMaterialLight 128 | } 129 | case .dark(let material): 130 | switch material { 131 | case .ultraThin: return .systemUltraThinMaterialDark 132 | case .thin: return .systemThinMaterialDark 133 | case .default: return .systemMaterialDark 134 | case .thick: return .systemThickMaterialDark 135 | case .chrome: return .systemChromeMaterialDark 136 | } 137 | } 138 | } 139 | } 140 | #elseif os(macOS) 141 | fileprivate extension VisualEffect { 142 | var material: NSVisualEffectView.Material { 143 | switch self { 144 | case .system, .systemLight, .systemDark: 145 | return .contentBackground 146 | case .adaptive(let material), .light(let material), .dark(let material): 147 | switch material { 148 | case .default, .contentBackground: return .contentBackground 149 | case .titlebar: return .titlebar 150 | case .headerView: return .headerView 151 | case .behindPageBackground: return .underPageBackground 152 | case .windowBackground: return .windowBackground 153 | } 154 | } 155 | } 156 | 157 | var blendingMode: NSVisualEffectView.BlendingMode { 158 | switch self { 159 | case .system, .systemLight, .systemDark: 160 | return .behindWindow 161 | case .adaptive(let material), 162 | .light(let material), 163 | .dark(let material): 164 | switch material { 165 | case .default, .windowBackground: 166 | return .behindWindow 167 | case .titlebar: 168 | return .withinWindow 169 | case .contentBackground(let behindWindow), 170 | .headerView(let behindWindow), 171 | .behindPageBackground(let behindWindow): 172 | return behindWindow ? .behindWindow : .withinWindow 173 | } 174 | } 175 | } 176 | 177 | var appearance: NSAppearance? { 178 | switch self { 179 | case .system, .adaptive: return nil 180 | case .systemLight, .light: return NSAppearance(named: .aqua) 181 | case .systemDark, .dark: return NSAppearance(named: .darkAqua) 182 | } 183 | } 184 | } 185 | #endif 186 | 187 | #if os(macOS) 188 | internal struct VisualEffectView: NSViewRepresentable { 189 | 190 | var visualEffect: VisualEffect 191 | 192 | func makeNSView(context: Context) -> NSVisualEffectView { 193 | let view = NSVisualEffectView() 194 | view.material = self.visualEffect.material 195 | view.blendingMode = self.visualEffect.blendingMode 196 | view.appearance = self.visualEffect.appearance 197 | 198 | // mark emphasized if it contains the first responder 199 | if let resp = view.window?.firstResponder as? NSView { 200 | view.isEmphasized = resp === view || resp.isDescendant( 201 | of: view 202 | ) 203 | } else { 204 | view.isEmphasized = false 205 | } 206 | view.autoresizingMask = [ 207 | .width, 208 | .height 209 | ] 210 | return view 211 | } 212 | 213 | func updateNSView( 214 | _ nsView: NSVisualEffectView, 215 | context: Context 216 | ) { 217 | nsView.material = self.visualEffect.material 218 | nsView.blendingMode = self.visualEffect.blendingMode 219 | nsView.appearance = self.visualEffect.appearance 220 | 221 | // mark emphasized if it contains the first responder 222 | if let resp = nsView.window?.firstResponder as? NSView { 223 | nsView.isEmphasized = resp === nsView || resp.isDescendant(of: nsView) 224 | } else { 225 | nsView.isEmphasized = false 226 | } 227 | } 228 | } 229 | #elseif canImport(UIKit) 230 | internal struct VisualEffectView: UIViewRepresentable { 231 | 232 | var visualEffect: VisualEffect 233 | 234 | func makeUIView(context: Context) -> UIVisualEffectView { 235 | let view = UIVisualEffectView(effect: UIBlurEffect(style: self.visualEffect.blurStyle)) 236 | view.autoresizingMask = [ 237 | .flexibleWidth, 238 | .flexibleHeight 239 | ] 240 | return view 241 | } 242 | 243 | func updateUIView( 244 | _ uiView: UIVisualEffectView, 245 | context: Context 246 | ) { 247 | uiView.effect = UIBlurEffect(style: self.visualEffect.blurStyle) 248 | } 249 | } 250 | #endif 251 | -------------------------------------------------------------------------------- /Sources/BottomSheet/Helper/AppleScrollBehaviourScrollView/UIScrollViewWrapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIScrollViewWrapper.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | #if !os(macOS) 9 | import SwiftUI 10 | 11 | internal struct UIScrollViewWrapper: UIViewControllerRepresentable { 12 | 13 | @State private var contentOffsetAnimation: TimerAnimation? 14 | @Binding private var isScrollEnabled: Bool 15 | @Binding private var dragState: DragGesture.DragState 16 | private var content: Content 17 | 18 | func makeUIViewController( 19 | context: UIViewControllerRepresentableContext 20 | ) -> UIScrollViewViewController { 21 | let viewController = UIScrollViewViewController(rootView: self.content) 22 | viewController.scrollView.delegate = context.coordinator 23 | return viewController 24 | } 25 | 26 | func updateUIViewController( 27 | _ viewController: UIScrollViewViewController, 28 | context: UIViewControllerRepresentableContext 29 | ) { 30 | // Update the content 31 | viewController.updateContent(self.content) 32 | 33 | // isScrollEnabled 34 | if viewController.scrollView.isScrollEnabled != self.isScrollEnabled { 35 | viewController.scrollView.isScrollEnabled = self.isScrollEnabled 36 | } 37 | 38 | // dragState 39 | switch self.dragState { 40 | case .none: 41 | return 42 | case .changed(value: let value): 43 | DispatchQueue.main.async { 44 | self.contentOffsetAnimation?.invalidate() 45 | self.contentOffsetAnimation = nil 46 | } 47 | 48 | let dims = viewController.scrollView.bounds.size.height 49 | let clampedY: CGFloat = max( 50 | min( 51 | max( 52 | -value.translation.height, 53 | 0 54 | ), 55 | viewController.scrollView.contentSize.height - viewController.scrollView.bounds.height 56 | ), 57 | 0 58 | ) 59 | let sign: CGFloat = clampedY > -value.translation.height ? -1 : 1 60 | let result: CGFloat = clampedY + sign * ( 61 | (1.0 - (1.0 / (abs(-value.translation.height - clampedY) * 0.55 / dims + 1.0))) * dims 62 | ) 63 | 64 | viewController.scrollView.contentOffset.y = result 65 | case .ended(value: let value): 66 | DispatchQueue.main.async { 67 | self.dragState = .none 68 | } 69 | 70 | let velocityY = (value.location.y - value.predictedEndLocation.y) / ( 71 | UIScrollView.DecelerationRate.normal.rawValue / ( 72 | 1000.0 * (1.0 - UIScrollView.DecelerationRate.normal.rawValue) 73 | ) 74 | ) 75 | self.completeGesture( 76 | with: velocityY, 77 | in: viewController 78 | ) 79 | } 80 | } 81 | 82 | func makeCoordinator() -> Coordinator { 83 | Coordinator( 84 | contentOffsetAnimation: self.$contentOffsetAnimation, 85 | isScrollEnabled: self.$isScrollEnabled 86 | ) 87 | } 88 | 89 | private func completeGesture( 90 | with velocityY: CGFloat, 91 | in viewController: UIScrollViewViewController 92 | ) { 93 | if !( 94 | viewController.scrollView.contentOffset.y < 0 || 95 | viewController.scrollView.contentOffset.y > max( 96 | viewController.scrollView.contentSize.height - viewController.scrollView.bounds.height, 97 | 0 98 | ) 99 | ) { 100 | self.startDeceleration( 101 | with: velocityY, 102 | in: viewController 103 | ) 104 | } else { 105 | self.bounce( 106 | with: velocityY, 107 | in: viewController 108 | ) 109 | } 110 | } 111 | 112 | private func startDeceleration( 113 | with velocityY: CGFloat, 114 | in viewController: UIScrollViewViewController 115 | ) { 116 | let initialValueY: CGFloat = viewController.scrollView.contentOffset.y 117 | let decelerationRate: CGFloat = UIScrollView.DecelerationRate.normal.rawValue 118 | let dCoeff = 1000 * log(decelerationRate) 119 | let duration: TimeInterval = velocityY == 0 ? 0 : TimeInterval( 120 | log(-dCoeff * 0.5 / abs(velocityY)) / dCoeff 121 | ) / 10 122 | 123 | DispatchQueue.main.async { 124 | self.contentOffsetAnimation = TimerAnimation( 125 | duration: duration, 126 | animations: { _, time in 127 | viewController.scrollView.contentOffset.y = initialValueY + (pow( 128 | decelerationRate, 129 | CGFloat(1000 * time) 130 | ) - 1) / dCoeff * velocityY 131 | }, 132 | completion: { finished in 133 | guard finished else { 134 | return 135 | } 136 | self.bounce( 137 | with: velocityY * pow( 138 | decelerationRate, 139 | CGFloat(1000 * duration) 140 | ), 141 | in: viewController 142 | ) 143 | } 144 | ) 145 | } 146 | } 147 | 148 | private func bounce( 149 | with velocityY: CGFloat, 150 | in viewController: UIScrollViewViewController 151 | ) { 152 | let restOffsetY = max( 153 | min( 154 | max( 155 | viewController.scrollView.contentOffset.y, 156 | 0 157 | ), 158 | viewController.scrollView.contentSize.height - viewController.scrollView.bounds.height 159 | ), 160 | 0 161 | ) 162 | let displacementY = viewController.scrollView.contentOffset.y - restOffsetY 163 | let threshold = 0.5 / UIScreen.main.scale 164 | 165 | let duration: TimeInterval = { 166 | if abs(displacementY) == 0 && abs(velocityY) == 0 { 167 | return 0 168 | } 169 | 170 | let timeInterval1 = 1 / 10 * log(2 * abs(displacementY) / threshold) 171 | let timeInterval2 = 2 / 10 * log(4 * abs(velocityY + 10 * displacementY) / (CGFloat(M_E) * 10 * threshold)) 172 | 173 | return TimeInterval( 174 | max( 175 | timeInterval1, 176 | timeInterval2 177 | ) 178 | ) 179 | }() 180 | 181 | DispatchQueue.main.async { 182 | self.contentOffsetAnimation = TimerAnimation( 183 | duration: duration, 184 | animations: { _, time in 185 | viewController.scrollView.contentOffset.y = restOffsetY + ( 186 | exp(-10 * time) * (displacementY + (velocityY + 10 * displacementY) * time) 187 | ) 188 | } 189 | ) 190 | } 191 | } 192 | 193 | class Coordinator: NSObject, UIScrollViewDelegate { 194 | 195 | @Binding fileprivate var contentOffsetAnimation: TimerAnimation? 196 | @Binding fileprivate var isScrollEnabled: Bool 197 | 198 | func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { 199 | DispatchQueue.main.async { 200 | self.contentOffsetAnimation?.invalidate() 201 | self.contentOffsetAnimation = nil 202 | } 203 | } 204 | 205 | func scrollViewDidEndDragging( 206 | _ scrollView: UIScrollView, 207 | willDecelerate decelerate: Bool 208 | ) { 209 | self.updateScroll(for: scrollView.contentOffset) 210 | } 211 | 212 | func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { 213 | self.updateScroll(for: scrollView.contentOffset) 214 | } 215 | 216 | private func updateScroll(for offset: CGPoint) { 217 | DispatchQueue.main.async { 218 | if offset.y <= 0 { 219 | self.isScrollEnabled = false 220 | } else { 221 | self.isScrollEnabled = true 222 | } 223 | } 224 | } 225 | 226 | fileprivate init( 227 | contentOffsetAnimation: Binding, 228 | isScrollEnabled: Binding 229 | ) { 230 | self._contentOffsetAnimation = contentOffsetAnimation 231 | self._isScrollEnabled = isScrollEnabled 232 | } 233 | } 234 | 235 | init( 236 | isScrollEnabled: Binding, 237 | dragState: Binding, 238 | @ViewBuilder content: @escaping () -> Content 239 | ) { 240 | self._isScrollEnabled = isScrollEnabled 241 | self._dragState = dragState 242 | self.content = content() 243 | } 244 | } 245 | 246 | internal class UIScrollViewViewController: UIViewController { 247 | 248 | fileprivate let scrollView: UIScrollView 249 | fileprivate let hostingController: UIHostingController 250 | 251 | override func viewDidLoad() { 252 | super.viewDidLoad() 253 | // Add the UIScrollView 254 | self.view.addSubview(self.scrollView) 255 | // Layout the ScrollView 256 | self.createConstraints() 257 | self.view.setNeedsUpdateConstraints() 258 | self.view.updateConstraintsIfNeeded() 259 | self.view.layoutIfNeeded() 260 | } 261 | 262 | // Update 263 | fileprivate func updateContent(_ content: Content) { 264 | self.hostingController.rootView = content 265 | self.scrollView.addSubview(self.hostingController.view) 266 | 267 | var contentSize: CGSize = self.hostingController.view.intrinsicContentSize 268 | contentSize.width = self.scrollView.frame.width 269 | self.hostingController.view.frame.size = contentSize 270 | self.scrollView.contentSize = contentSize 271 | self.view.updateConstraintsIfNeeded() 272 | self.view.layoutIfNeeded() 273 | } 274 | 275 | // ScrollView Constraints 276 | private func createConstraints() { 277 | NSLayoutConstraint.activate([ 278 | self.scrollView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), 279 | self.scrollView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), 280 | self.scrollView.topAnchor.constraint(equalTo: self.view.topAnchor), 281 | self.scrollView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor) 282 | ]) 283 | } 284 | 285 | fileprivate init(rootView: Content) { 286 | // Create the UIScrollView 287 | let scrollView = UIScrollView() 288 | scrollView.translatesAutoresizingMaskIntoConstraints = false 289 | scrollView.alwaysBounceVertical = true 290 | scrollView.backgroundColor = .clear 291 | self.scrollView = scrollView 292 | 293 | // Create the UIHostingController 294 | let hostingController = UIHostingController(rootView: rootView) 295 | hostingController.view.backgroundColor = .clear 296 | self.hostingController = hostingController 297 | 298 | super.init( 299 | nibName: nil, 300 | bundle: nil 301 | ) 302 | } 303 | 304 | required init?(coder: NSCoder) { 305 | fatalError("init(coder:) has not been implemented") 306 | } 307 | } 308 | #endif 309 | -------------------------------------------------------------------------------- /Sources/BottomSheet/BottomSheetView/BottomSheetView+SwitchPosition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheetView+SwitchPosition.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import SwiftUI 9 | 10 | internal extension BottomSheetView { 11 | 12 | // For `flickThrough` 13 | 14 | func dragPositionSwitch( 15 | with geometry: GeometryProxy, 16 | value: DragGesture.Value 17 | ) { 18 | if let dragPositionSwitchAction = self.configuration.dragPositionSwitchAction { 19 | dragPositionSwitchAction(geometry, value) 20 | } else { 21 | // On iPad floating and Mac the drag direction is reversed 22 | let translationHeight: CGFloat = self.isIPadFloatingOrMac ? -value.translation.height : value.translation.height 23 | // The height in percent relative to the screen height the user has dragged 24 | let height: CGFloat = translationHeight / geometry.size.height 25 | 26 | // An array with all switchablePositions sorted by height (low to high), 27 | // excluding .dynamic..., .hidden and the current position 28 | let switchablePositions = self.getSwitchablePositions(with: geometry) 29 | 30 | // The height of the currentBottomSheetPosition; if nil main content height is used 31 | let currentHeight = self.currentBottomSheetHeight(with: geometry) 32 | 33 | if self.configuration.isFlickThroughEnabled { 34 | self.switchPositionWithFlickThrough( 35 | with: height, 36 | switchablePositions: switchablePositions, 37 | currentHeight: currentHeight 38 | ) 39 | } else { 40 | self.switchPositionWithoutFlickThrough( 41 | with: height, 42 | switchablePositions: switchablePositions, 43 | currentHeight: currentHeight 44 | ) 45 | } 46 | } 47 | } 48 | 49 | private func switchPositionWithFlickThrough( 50 | with height: CGFloat, 51 | switchablePositions: [( 52 | height: CGFloat, 53 | position: BottomSheetPosition 54 | )], 55 | currentHeight: CGFloat 56 | ) { 57 | if height <= -0.1 && height > -self.configuration.threshold { 58 | // Go up one position 59 | self.onePositionSwitchUp( 60 | switchablePositions: switchablePositions, 61 | currentHeight: currentHeight 62 | ) 63 | } else if height <= -self.configuration.threshold { 64 | // Go up to highest position 65 | self.switchToHighestPosition( 66 | switchablePositions: switchablePositions, 67 | currentHeight: currentHeight 68 | ) 69 | } else if height >= 0.1 && height < self.configuration.threshold { 70 | // Go down one position 71 | self.onePositionSwitchDown( 72 | switchablePositions: switchablePositions, 73 | currentHeight: currentHeight 74 | ) 75 | } else if height >= self.configuration.threshold && self.configuration.isSwipeToDismissEnabled { 76 | self.closeSheet() 77 | } else if height >= self.configuration.threshold { 78 | // Go down to lowest position 79 | self.switchToLowestPosition( 80 | switchablePositions: switchablePositions, 81 | currentHeight: currentHeight 82 | ) 83 | } 84 | } 85 | 86 | // swiftlint:disable cyclomatic_complexity 87 | private func switchToHighestPosition( 88 | switchablePositions: [( 89 | height: CGFloat, 90 | position: BottomSheetPosition 91 | )], 92 | currentHeight: CGFloat 93 | ) { 94 | switch self.bottomSheetPosition { 95 | case .hidden: 96 | return 97 | case .dynamicBottom: 98 | if self.switchablePositions.contains(.dynamicTop) { 99 | // 1. dynamicTop 100 | self.bottomSheetPosition = .dynamicTop 101 | } else if self.switchablePositions.contains(.dynamic) { 102 | // 2. dynamic 103 | self.bottomSheetPosition = .dynamic 104 | } else if let highest = switchablePositions.last, highest.height > currentHeight { 105 | // 3. highest position 106 | self.bottomSheetPosition = highest.position 107 | } 108 | case .dynamic: 109 | if self.switchablePositions.contains(.dynamicTop) { 110 | // 1. dynamicTop 111 | self.bottomSheetPosition = .dynamicTop 112 | } else if let highest = switchablePositions.last, highest.height > currentHeight { 113 | // 2. highest position 114 | self.bottomSheetPosition = highest.position 115 | } 116 | default: 117 | if let highest = switchablePositions.last, highest.height > currentHeight { 118 | // 1. highest position 119 | self.bottomSheetPosition = highest.position 120 | } else if self.switchablePositions.contains(.dynamicTop) { 121 | // 2. dynamicTop 122 | self.bottomSheetPosition = .dynamicTop 123 | } else if self.switchablePositions.contains(.dynamic) { 124 | // 3. dynamic 125 | self.bottomSheetPosition = .dynamic 126 | } else if self.switchablePositions.contains(.dynamicBottom) { 127 | // 4. dynamicBottom 128 | self.bottomSheetPosition = .dynamicBottom 129 | } 130 | } 131 | } 132 | 133 | private func switchToLowestPosition( 134 | switchablePositions: [( 135 | height: CGFloat, 136 | position: BottomSheetPosition 137 | )], 138 | currentHeight: CGFloat 139 | ) { 140 | switch self.bottomSheetPosition { 141 | case .hidden: 142 | return 143 | case .dynamicTop: 144 | if self.switchablePositions.contains(.dynamicBottom) { 145 | // 1. dynamicBottom 146 | self.bottomSheetPosition = .dynamicBottom 147 | } else if self.switchablePositions.contains(.dynamic) { 148 | // 2. dynamic 149 | self.bottomSheetPosition = .dynamic 150 | } else if let lowest = switchablePositions.first, lowest.height < currentHeight { 151 | // 3. lowest position that is lower than the current one 152 | self.bottomSheetPosition = lowest.position 153 | } 154 | case .dynamic: 155 | if self.switchablePositions.contains(.dynamicBottom) { 156 | // 1. dynamicBottom 157 | self.bottomSheetPosition = .dynamicBottom 158 | } else if let lowest = switchablePositions.first, lowest.height < currentHeight { 159 | // 2. lowest position that is lower than the current one 160 | self.bottomSheetPosition = lowest.position 161 | } 162 | default: 163 | if let lowest = switchablePositions.first, lowest.height < currentHeight { 164 | // 1. lowest position that is lower than the current one 165 | self.bottomSheetPosition = lowest.position 166 | } else if self.switchablePositions.contains(.dynamicBottom) { 167 | // 2. dynamicBottom 168 | self.bottomSheetPosition = .dynamicTop 169 | } else if self.switchablePositions.contains(.dynamic) { 170 | // 3. dynamic 171 | self.bottomSheetPosition = .dynamic 172 | } else if self.switchablePositions.contains(.dynamicTop) { 173 | // 4. dynamicTop 174 | self.bottomSheetPosition = .dynamicTop 175 | } 176 | } 177 | } 178 | // swiftlint:enable cyclomatic_complexity 179 | 180 | private func switchPositionWithoutFlickThrough( 181 | with height: CGFloat, 182 | switchablePositions: [( 183 | height: CGFloat, 184 | position: BottomSheetPosition 185 | )], 186 | currentHeight: CGFloat 187 | ) { 188 | if height <= -0.1 { 189 | // Go up one position 190 | self.onePositionSwitchUp( 191 | switchablePositions: switchablePositions, 192 | currentHeight: currentHeight 193 | ) 194 | } else if height >= self.configuration.threshold && self.configuration.isSwipeToDismissEnabled { 195 | self.closeSheet() 196 | } else if height >= 0.1 { 197 | // Go down one position 198 | self.onePositionSwitchDown( 199 | switchablePositions: switchablePositions, 200 | currentHeight: currentHeight 201 | ) 202 | } 203 | } 204 | 205 | private func onePositionSwitchUp( 206 | switchablePositions: [( 207 | height: CGFloat, 208 | position: BottomSheetPosition 209 | )], 210 | currentHeight: CGFloat 211 | ) { 212 | switch self.bottomSheetPosition { 213 | case .hidden: 214 | return 215 | case .dynamicBottom: 216 | if self.switchablePositions.contains(.dynamic) { 217 | // 1. dynamic 218 | self.bottomSheetPosition = .dynamic 219 | } else { 220 | fallthrough 221 | } 222 | case .dynamic: 223 | if self.switchablePositions.contains(.dynamicTop) { 224 | // 1. dynamicTop 225 | self.bottomSheetPosition = .dynamicTop 226 | } else { 227 | fallthrough 228 | } 229 | default: 230 | if let position = switchablePositions.first(where: { 231 | $0.height > currentHeight 232 | })?.position { 233 | // 1. lowest value that is higher than current height 234 | self.bottomSheetPosition = position 235 | } else if self.bottomSheetPosition.isBottom { 236 | // 2. if currently bottom 237 | if self.switchablePositions.contains(.dynamic) { 238 | // 2.1 dynamic 239 | self.bottomSheetPosition = .dynamic 240 | } else if self.switchablePositions.contains(.dynamicTop) { 241 | // 2.2 dynamicTop 242 | self.bottomSheetPosition = .dynamicTop 243 | } 244 | } else if !self.bottomSheetPosition.isTop { 245 | // 3. if currently "medium" 246 | if self.switchablePositions.contains(.dynamicTop) { 247 | // 3.1 dynamicTop 248 | self.bottomSheetPosition = .dynamicTop 249 | } 250 | } 251 | } 252 | } 253 | 254 | private func onePositionSwitchDown( 255 | switchablePositions: [( 256 | height: CGFloat, 257 | position: BottomSheetPosition 258 | )], 259 | currentHeight: CGFloat 260 | ) { 261 | switch self.bottomSheetPosition { 262 | case .hidden: 263 | return 264 | case .dynamicTop: 265 | if self.switchablePositions.contains(.dynamic) { 266 | // 1. dynamic 267 | self.bottomSheetPosition = .dynamic 268 | } else { 269 | fallthrough 270 | } 271 | case .dynamic: 272 | if self.switchablePositions.contains(.dynamicBottom) { 273 | // 1. dynamicBottom 274 | self.bottomSheetPosition = .dynamicBottom 275 | } else { 276 | fallthrough 277 | } 278 | default: 279 | if let position = switchablePositions.last(where: { 280 | $0.height < currentHeight 281 | })?.position { 282 | // 1. highest value that is lower than current height 283 | self.bottomSheetPosition = position 284 | } else if self.bottomSheetPosition.isTop { 285 | // 2. if currently top 286 | if self.switchablePositions.contains(.dynamic) { 287 | // 2.1 dynamic 288 | self.bottomSheetPosition = .dynamic 289 | } else if self.switchablePositions.contains(.dynamicBottom) { 290 | // 2.2 dynamicBottom 291 | self.bottomSheetPosition = .dynamicBottom 292 | } 293 | } else if !self.bottomSheetPosition.isBottom { 294 | // 3. if currently "medium" 295 | if self.switchablePositions.contains(.dynamicBottom) { 296 | // 3.1 dynamicTop 297 | self.bottomSheetPosition = .dynamicBottom 298 | } 299 | } 300 | } 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /Sources/BottomSheet/BottomSheetView/BottomSheetView+DragIndicatorAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheetView+DragIndicatorAction.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import SwiftUI 9 | 10 | internal extension BottomSheetView { 11 | 12 | // For `dragIndicator` 13 | 14 | func dragIndicatorAction(with geometry: GeometryProxy) { 15 | if let dragIndicatorAction = self.configuration.dragIndicatorAction { 16 | dragIndicatorAction(geometry) 17 | } else { 18 | // An array with all switchablePositions sorted by height (low to high), 19 | // excluding .dynamic..., .hidden and the current position 20 | let switchablePositions = self.getSwitchablePositions(with: geometry) 21 | 22 | // The height of the currentBottomSheetPosition; if nil main content height is used 23 | let currentHeight = self.currentBottomSheetHeight(with: geometry) 24 | 25 | switch self.bottomSheetPosition { 26 | case .hidden: 27 | return 28 | case .dynamicBottom: 29 | self.dynamicBottomSwitch( 30 | switchablePositions: switchablePositions, 31 | currentHeight: currentHeight 32 | ) 33 | case .dynamic: 34 | self.dynamicSwitch( 35 | switchablePositions: switchablePositions, 36 | currentHeight: currentHeight 37 | ) 38 | case .dynamicTop: 39 | self.dynamicTopSwitch( 40 | switchablePositions: switchablePositions, 41 | currentHeight: currentHeight 42 | ) 43 | case .relativeBottom, .absoluteBottom: 44 | self.valueBottomSwitch( 45 | switchablePositions: switchablePositions, 46 | currentHeight: currentHeight 47 | ) 48 | case .relative, .absolute: 49 | self.valueSwitch( 50 | switchablePositions: switchablePositions, 51 | currentHeight: currentHeight 52 | ) 53 | case .relativeTop, .absoluteTop: 54 | self.valueTopSwitch( 55 | switchablePositions: switchablePositions, 56 | currentHeight: currentHeight 57 | ) 58 | } 59 | 60 | self.endEditing() 61 | } 62 | } 63 | 64 | private func dynamicBottomSwitch( 65 | switchablePositions: [( 66 | height: CGFloat, 67 | position: BottomSheetPosition 68 | )], 69 | currentHeight: CGFloat 70 | ) { 71 | if self.switchablePositions.contains(.dynamic) { 72 | // 1. dynamic 73 | self.bottomSheetPosition = .dynamic 74 | } else if let position = switchablePositions.first(where: { 75 | !$0.position.isTop && !$0.position.isBottom && $0.height > currentHeight 76 | })?.position { 77 | // 2. lowest value that is "middle" and higher than current height 78 | self.bottomSheetPosition = position 79 | } else if self.switchablePositions.contains(.dynamicTop) { 80 | // 3. dynamicTop 81 | self.bottomSheetPosition = .dynamicTop 82 | } else if let position = switchablePositions.first(where: { 83 | $0.position.isTop && $0.height > currentHeight 84 | })?.position { 85 | // 4. lowest value that is top and higher than current height 86 | self.bottomSheetPosition = position 87 | } else if let position = switchablePositions.first(where: { 88 | $0.position.isBottom && $0.height > currentHeight 89 | })?.position { 90 | // 5. lowest value that is bottom and higher than current height 91 | self.bottomSheetPosition = position 92 | } else if let position = switchablePositions.last(where: { 93 | !$0.position.isTop && !$0.position.isBottom 94 | })?.position { 95 | // 6. highest value that is "middle" 96 | self.bottomSheetPosition = position 97 | } else if let position = switchablePositions.last(where: { 98 | $0.position.isTop 99 | })?.position { 100 | // 7. highest value that is top 101 | self.bottomSheetPosition = position 102 | } else if let position = switchablePositions.last(where: { 103 | $0.position.isBottom 104 | })?.position { 105 | // 8. highest value that is bottom 106 | self.bottomSheetPosition = position 107 | } 108 | } 109 | 110 | private func dynamicSwitch( 111 | switchablePositions: [( 112 | height: CGFloat, 113 | position: BottomSheetPosition 114 | )], 115 | currentHeight: CGFloat 116 | ) { 117 | if self.switchablePositions.contains(.dynamicTop) { 118 | // 1. dynamicTop 119 | self.bottomSheetPosition = .dynamicTop 120 | } else if let position = switchablePositions.first(where: { 121 | !$0.position.isTop && !$0.position.isBottom && $0.height > currentHeight 122 | })?.position { 123 | // 2. lowest value that is "middle" and higher than current height 124 | self.bottomSheetPosition = position 125 | } else if let position = switchablePositions.first(where: { 126 | $0.position.isTop && $0.height > currentHeight 127 | })?.position { 128 | // 3. lowest value that is top and higher than current height 129 | self.bottomSheetPosition = position 130 | } else if let position = switchablePositions.first(where: { 131 | $0.position.isBottom && $0.height > currentHeight 132 | })?.position { 133 | // 4. lowest value that is bottom and higher than current height 134 | self.bottomSheetPosition = position 135 | } else if let position = switchablePositions.last(where: { 136 | $0.position.isTop 137 | })?.position { 138 | // 5. highest value that is top 139 | self.bottomSheetPosition = position 140 | } else if let position = switchablePositions.last(where: { 141 | !$0.position.isTop && !$0.position.isBottom 142 | })?.position { 143 | // 6. highest value that is "middle" 144 | self.bottomSheetPosition = position 145 | } else if self.switchablePositions.contains(.dynamicBottom) { 146 | // 7. dynamicBottom 147 | self.bottomSheetPosition = .dynamicBottom 148 | } else if let position = switchablePositions.last(where: { 149 | $0.position.isBottom 150 | })?.position { 151 | // 8. highest value that is bottom 152 | self.bottomSheetPosition = position 153 | } 154 | } 155 | 156 | private func dynamicTopSwitch( 157 | switchablePositions: [( 158 | height: CGFloat, 159 | position: BottomSheetPosition 160 | )], 161 | currentHeight: CGFloat 162 | ) { 163 | if self.switchablePositions.contains(.dynamic) { 164 | // 1. dynamic 165 | self.bottomSheetPosition = .dynamic 166 | } else if let position = switchablePositions.last(where: { 167 | !$0.position.isTop && !$0.position.isBottom && $0.height < currentHeight 168 | })?.position { 169 | // 2. highest value that is "middle" and lower than current height 170 | self.bottomSheetPosition = position 171 | } else if self.switchablePositions.contains(.dynamicBottom) { 172 | // 3. dynamicBottom 173 | self.bottomSheetPosition = .dynamicBottom 174 | } else if let position = switchablePositions.last(where: { 175 | $0.position.isBottom && $0.height < currentHeight 176 | })?.position { 177 | // 4. highest value that is bottom and lower than current height 178 | self.bottomSheetPosition = position 179 | } else if let position = switchablePositions.last(where: { 180 | $0.position.isTop && $0.height < currentHeight 181 | })?.position { 182 | // 5. highest value that is top and lower than current height 183 | self.bottomSheetPosition = position 184 | } else if let position = switchablePositions.first(where: { 185 | !$0.position.isTop && !$0.position.isBottom 186 | })?.position { 187 | // 6. lowest value that is "middle" 188 | self.bottomSheetPosition = position 189 | } else if let position = switchablePositions.first(where: { 190 | $0.position.isBottom 191 | })?.position { 192 | // 7. lowest value that is bottom 193 | self.bottomSheetPosition = position 194 | } else if let position = switchablePositions.first(where: { 195 | $0.position.isTop 196 | })?.position { 197 | // 8. lowest value that is top 198 | self.bottomSheetPosition = position 199 | } 200 | } 201 | 202 | private func valueBottomSwitch( 203 | switchablePositions: [( 204 | height: CGFloat, 205 | position: BottomSheetPosition 206 | )], 207 | currentHeight: CGFloat 208 | ) { 209 | if let position = switchablePositions.first(where: { 210 | !$0.position.isTop && !$0.position.isBottom && $0.height > currentHeight 211 | })?.position { 212 | // 1. lowest value that is "middle" and higher than current height 213 | self.bottomSheetPosition = position 214 | } else if self.switchablePositions.contains(.dynamic) { 215 | // 2. dynamic 216 | self.bottomSheetPosition = .dynamic 217 | } else if let position = switchablePositions.first(where: { 218 | $0.position.isTop && $0.height > currentHeight 219 | })?.position { 220 | // 3. lowest value that is top and higher than current height 221 | self.bottomSheetPosition = position 222 | } else if self.switchablePositions.contains(.dynamicTop) { 223 | // 4. dynamicTop 224 | self.bottomSheetPosition = .dynamicTop 225 | } else if let position = switchablePositions.first(where: { 226 | $0.position.isBottom && $0.height > currentHeight 227 | })?.position { 228 | // 5. lowest value that is bottom and higher than current height 229 | self.bottomSheetPosition = position 230 | } else if let position = switchablePositions.last(where: { 231 | !$0.position.isTop && !$0.position.isBottom 232 | })?.position { 233 | // 6. highest value that is "middle" 234 | self.bottomSheetPosition = position 235 | } else if let position = switchablePositions.last(where: { 236 | $0.position.isTop 237 | })?.position { 238 | // 7. highest value that is top 239 | self.bottomSheetPosition = position 240 | } else if let position = switchablePositions.last(where: {$0.position.isBottom 241 | })?.position { 242 | // 8. highest value that is bottom 243 | self.bottomSheetPosition = position 244 | } else if self.switchablePositions.contains(.dynamicBottom) { 245 | // 9. dynamicBottom 246 | self.bottomSheetPosition = .dynamicBottom 247 | } 248 | } 249 | 250 | private func valueSwitch( 251 | switchablePositions: [( 252 | height: CGFloat, 253 | position: BottomSheetPosition 254 | )], 255 | currentHeight: CGFloat 256 | ) { 257 | if let position = switchablePositions.first(where: { 258 | !$0.position.isTop && !$0.position.isBottom && $0.height > currentHeight 259 | })?.position { 260 | // 1. lowest value that is "middle" and higher than current height 261 | self.bottomSheetPosition = position 262 | } else if let position = switchablePositions.first(where: { 263 | $0.position.isTop && $0.height > currentHeight 264 | })?.position { 265 | // 2. lowest value that is top and higher than current height 266 | self.bottomSheetPosition = position 267 | } else if self.switchablePositions.contains(.dynamicTop) { 268 | // 3. dynamicTop 269 | self.bottomSheetPosition = .dynamicTop 270 | } else if let position = switchablePositions.first(where: { 271 | $0.position.isBottom && $0.height > currentHeight 272 | })?.position { 273 | // 4. lowest value that is bottom and higher than current height 274 | self.bottomSheetPosition = position 275 | } else if let position = switchablePositions.last(where: { 276 | $0.position.isTop 277 | })?.position { 278 | // 5. highest value that is top 279 | self.bottomSheetPosition = position 280 | } else if self.switchablePositions.contains(.dynamic) { 281 | // 6. dynamic 282 | self.bottomSheetPosition = .dynamic 283 | } else if let position = switchablePositions.last(where: { 284 | !$0.position.isTop && !$0.position.isBottom 285 | })?.position { 286 | // 7. highest value that is "middle" 287 | self.bottomSheetPosition = position 288 | } else if let position = switchablePositions.last(where: { 289 | $0.position.isBottom 290 | })?.position { 291 | // 8. highest value that is bottom 292 | self.bottomSheetPosition = position 293 | } else if self.switchablePositions.contains(.dynamicBottom) { 294 | // 9. dynamicBottom 295 | self.bottomSheetPosition = .dynamicBottom 296 | } 297 | } 298 | 299 | private func valueTopSwitch( 300 | switchablePositions: [( 301 | height: CGFloat, 302 | position: BottomSheetPosition 303 | )], 304 | currentHeight: CGFloat 305 | ) { 306 | if let position = switchablePositions.last(where: { 307 | !$0.position.isTop && !$0.position.isBottom && $0.height < currentHeight 308 | })?.position { 309 | // 1. highest value that is "middle" and lower than current height 310 | self.bottomSheetPosition = position 311 | } else if self.switchablePositions.contains(.dynamic) { 312 | // 2. dynamic 313 | self.bottomSheetPosition = .dynamic 314 | } else if let position = switchablePositions.last(where: { 315 | $0.position.isBottom && $0.height < currentHeight 316 | })?.position { 317 | // 3. highest value that is bottom and lower than current height 318 | self.bottomSheetPosition = position 319 | } else if self.switchablePositions.contains(.dynamicBottom) { 320 | // 4. dynamicBottom 321 | self.bottomSheetPosition = .dynamicBottom 322 | } else if let position = switchablePositions.first(where: { 323 | $0.position.isTop && $0.height < currentHeight 324 | })?.position { 325 | // 5. highest value that is top and lower than current height 326 | self.bottomSheetPosition = position 327 | } else if self.switchablePositions.contains(.dynamicTop) { 328 | // 6. dynamicTop 329 | self.bottomSheetPosition = .dynamicTop 330 | } else if let position = switchablePositions.last(where: { 331 | !$0.position.isTop && !$0.position.isBottom 332 | })?.position { 333 | // 7. highest value that is "middle" 334 | self.bottomSheetPosition = position 335 | } else if let position = switchablePositions.last(where: { 336 | $0.position.isBottom 337 | })?.position { 338 | // 8. highest value that is bottom 339 | self.bottomSheetPosition = position 340 | } else if let position = switchablePositions.first(where: { 341 | $0.position.isTop 342 | })?.position { 343 | // 9. lowest value that is top 344 | self.bottomSheetPosition = position 345 | } 346 | } 347 | } 348 | -------------------------------------------------------------------------------- /Sources/BottomSheet/BottomSheet+ViewModifiers/BottomSheet+CustomBackground.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheet+CustomBackground.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public extension BottomSheet { 11 | 12 | /// Layers the given view behind this view. 13 | /// 14 | /// Use `background(_:alignment:)` when you need to place one view behind 15 | /// another, with the background view optionally aligned with a specified 16 | /// edge of the frontmost view. 17 | /// 18 | /// The example below creates two views: the `Frontmost` view, and the 19 | /// `DiamondBackground` view. The `Frontmost` view uses the 20 | /// `DiamondBackground` view for the background of the image element inside 21 | /// the `Frontmost` view's ``VStack``. 22 | /// 23 | /// struct DiamondBackground: View { 24 | /// var body: some View { 25 | /// VStack { 26 | /// Rectangle() 27 | /// .fill(.gray) 28 | /// .frame(width: 250, height: 250, alignment: .center) 29 | /// .rotationEffect(.degrees(45.0)) 30 | /// } 31 | /// } 32 | /// } 33 | /// 34 | /// struct Frontmost: View { 35 | /// var body: some View { 36 | /// VStack { 37 | /// Image(systemName: "folder") 38 | /// .font(.system(size: 128, weight: .ultraLight)) 39 | /// .background(DiamondBackground()) 40 | /// } 41 | /// } 42 | /// } 43 | /// 44 | /// ![A view showing a large folder image with a gray diamond placed behind 45 | /// it as its background view.](View-background-1) 46 | /// 47 | /// - Parameters: 48 | /// - background: The view to draw behind this view. 49 | /// - alignment: The alignment with a default value of 50 | /// ``Alignment/center`` that you use to position the background view. 51 | func customBackground( 52 | _ background: Background, 53 | alignment: Alignment = .center 54 | ) -> BottomSheet where Background: View { 55 | self.configuration.backgroundView = AnyView(background) 56 | self.configuration.backgroundViewID = UUID() 57 | return self 58 | } 59 | 60 | /// Layers the views that you specify behind this view. 61 | /// 62 | /// Use this modifier to place one or more views behind another view. 63 | /// For example, you can place a collection of stars behind a ``Text`` view: 64 | /// 65 | /// Text("ABCDEF") 66 | /// .background(alignment: .leading) { Star(color: .red) } 67 | /// .background(alignment: .center) { Star(color: .green) } 68 | /// .background(alignment: .trailing) { Star(color: .blue) } 69 | /// 70 | /// The example above assumes that you've defined a `Star` view with a 71 | /// parameterized color: 72 | /// 73 | /// struct Star: View { 74 | /// var color: Color 75 | /// 76 | /// var body: some View { 77 | /// Image(systemName: "star.fill") 78 | /// .foregroundStyle(color) 79 | /// } 80 | /// } 81 | /// 82 | /// By setting different `alignment` values for each modifier, you make the 83 | /// stars appear in different places behind the text: 84 | /// 85 | /// ![A screenshot of the letters A, B, C, D, E, and F written in front of 86 | /// three stars. The stars, from left to right, are red, green, and 87 | /// blue.](View-background-2) 88 | /// 89 | /// If you specify more than one view in the `content` closure, the modifier 90 | /// collects all of the views in the closure into an implicit ``ZStack``, 91 | /// taking them in order from back to front. For example, you can layer a 92 | /// vertical bar behind a circle, with both of those behind a horizontal 93 | /// bar: 94 | /// 95 | /// Color.blue 96 | /// .frame(width: 200, height: 10) // Creates a horizontal bar. 97 | /// .background { 98 | /// Color.green 99 | /// .frame(width: 10, height: 100) // Creates a vertical bar. 100 | /// Circle() 101 | /// .frame(width: 50, height: 50) 102 | /// } 103 | /// 104 | /// Both the background modifier and the implicit ``ZStack`` composed from 105 | /// the background content --- the circle and the vertical bar --- use a 106 | /// default ``Alignment/center`` alignment. The vertical bar appears 107 | /// centered behind the circle, and both appear as a composite view centered 108 | /// behind the horizontal bar: 109 | /// 110 | /// ![A screenshot of a circle with a horizontal blue bar layered on top 111 | /// and a vertical green bar layered underneath. All of the items are center 112 | /// aligned.](View-background-3) 113 | /// 114 | /// If you specify an alignment for the background, it applies to the 115 | /// implicit stack rather than to the individual views in the closure. You 116 | /// can see this if you add the ``Alignment/leading`` alignment: 117 | /// 118 | /// Color.blue 119 | /// .frame(width: 200, height: 10) 120 | /// .background(alignment: .leading) { 121 | /// Color.green 122 | /// .frame(width: 10, height: 100) 123 | /// Circle() 124 | /// .frame(width: 50, height: 50) 125 | /// } 126 | /// 127 | /// The vertical bar and the circle move as a unit to align the stack 128 | /// with the leading edge of the horizontal bar, while the 129 | /// vertical bar remains centered on the circle: 130 | /// 131 | /// ![A screenshot of a horizontal blue bar in front of a circle, which 132 | /// is in front of a vertical green bar. The horizontal bar and the circle 133 | /// are center aligned with each other; the left edges of the circle 134 | /// and the horizontal are aligned.](View-background-3a) 135 | /// 136 | /// To control the placement of individual items inside the `content` 137 | /// closure, either use a different background modifier for each item, as 138 | /// the earlier example of stars under text demonstrates, or add an explicit 139 | /// ``ZStack`` inside the content closure with its own alignment: 140 | /// 141 | /// Color.blue 142 | /// .frame(width: 200, height: 10) 143 | /// .background(alignment: .leading) { 144 | /// ZStack(alignment: .leading) { 145 | /// Color.green 146 | /// .frame(width: 10, height: 100) 147 | /// Circle() 148 | /// .frame(width: 50, height: 50) 149 | /// } 150 | /// } 151 | /// 152 | /// The stack alignment ensures that the circle's leading edge aligns with 153 | /// the vertical bar's, while the background modifier aligns the composite 154 | /// view with the horizontal bar: 155 | /// 156 | /// ![A screenshot of a horizontal blue bar in front of a circle, which 157 | /// is in front of a vertical green bar. All items are aligned on their 158 | /// left edges.](View-background-4) 159 | /// 160 | /// You can achieve layering without a background modifier by putting both 161 | /// the modified view and the background content into a ``ZStack``. This 162 | /// produces a simpler view hierarchy, but it changes the layout priority 163 | /// that SwiftUI applies to the views. Use the background modifier when you 164 | /// want the modified view to dominate the layout. 165 | /// 166 | /// If you want to specify a ``ShapeStyle`` like a 167 | /// ``HierarchicalShapeStyle`` or a ``Material`` as the background, use 168 | /// ``View/background(_:ignoresSafeAreaEdges:)`` instead. 169 | /// To specify a ``Shape`` or ``InsettableShape``, use 170 | /// ``View/background(_:in:fillStyle:)-89n7j`` or 171 | /// ``View/background(_:in:fillStyle:)-20tq5``, respectively. 172 | /// 173 | /// - Parameters: 174 | /// - alignment: The alignment that the modifier uses to position the 175 | /// implicit ``ZStack`` that groups the background views. The default 176 | /// is ``Alignment/center``. 177 | /// - content: A ``ViewBuilder`` that you use to declare the views to draw 178 | /// behind this view, stacked in a cascading order from bottom to top. 179 | /// The last view that you list appears at the front of the stack. 180 | /// 181 | /// - Returns: A view that uses the specified content as a background. 182 | func customBackground( 183 | alignment: Alignment = .center, 184 | @ViewBuilder content: () -> Content 185 | ) -> BottomSheet { 186 | self.configuration.backgroundView = AnyView(content()) 187 | self.configuration.backgroundViewID = UUID() 188 | return self 189 | } 190 | 191 | /// Sets the view's background to a style. 192 | /// 193 | /// Use this modifier to place a type that conforms to the ``ShapeStyle`` 194 | /// protocol --- like a ``Color``, ``Material``, or 195 | /// ``HierarchicalShapeStyle`` --- behind a view. For example, you can add 196 | /// the ``ShapeStyle/regularMaterial`` behind a ``Label``: 197 | /// 198 | /// struct FlagLabel: View { 199 | /// var body: some View { 200 | /// Label("Flag", systemImage: "flag.fill") 201 | /// .padding() 202 | /// .background(.regularMaterial) 203 | /// } 204 | /// } 205 | /// 206 | /// SwiftUI anchors the style to the view's bounds. For the example above, 207 | /// the background fills the entirety of the label's frame, which includes 208 | /// the padding: 209 | /// 210 | /// ![A screenshot of a flag symbol and the word flag layered over a 211 | /// gray rectangle.](View-background-5) 212 | /// 213 | /// SwiftUI limits the background style's extent to the modified view's 214 | /// container-relative shape. You can see this effect if you constrain the 215 | /// `FlagLabel` view with a ``View/containerShape(_:)`` modifier: 216 | /// 217 | /// FlagLabel() 218 | /// .containerShape(RoundedRectangle(cornerRadius: 16)) 219 | /// 220 | /// The background takes on the specified container shape: 221 | /// 222 | /// ![A screenshot of a flag symbol and the word flag layered over a 223 | /// gray rectangle with rounded corners.](View-background-6) 224 | /// 225 | /// By default, the background ignores safe area insets on all edges, but 226 | /// you can provide a specific set of edges to ignore, or an empty set to 227 | /// respect safe area insets on all edges: 228 | /// 229 | /// Rectangle() 230 | /// .background( 231 | /// .regularMaterial, 232 | /// ignoresSafeAreaEdges: []) // Ignore no safe area insets. 233 | /// 234 | /// If you want to specify a ``View`` or a stack of views as the background, 235 | /// use ``View/background(alignment:content:)`` instead. 236 | /// To specify a ``Shape`` or ``InsettableShape``, use 237 | /// ``View/background(_:in:fillStyle:)-89n7j`` or 238 | /// ``View/background(_:in:fillStyle:)-20tq5``, respectively. 239 | /// 240 | /// - Parameters: 241 | /// - style: An instance of a type that conforms to ``ShapeStyle`` that 242 | /// SwiftUI draws behind the modified view. 243 | /// - edges: The set of edges for which to ignore safe area insets 244 | /// when adding the background. The default value is ``Edge/Set/all``. 245 | /// Specify an empty set to respect safe area insets on all edges. 246 | /// 247 | /// - Returns: A view with the specified style drawn behind it. 248 | func customBackground( 249 | _ style: S, 250 | ignoresSafeAreaEdges edges: Edge.Set = .all 251 | ) -> BottomSheet where S: ShapeStyle { 252 | self.configuration.backgroundView = AnyView( 253 | Rectangle() 254 | .fill(style) 255 | .edgesIgnoringSafeArea(edges) 256 | ) 257 | self.configuration.backgroundViewID = UUID() 258 | return self 259 | } 260 | 261 | /// Sets the view's background to a shape filled with a style. 262 | /// 263 | /// Use this modifier to layer a type that conforms to the ``Shape`` 264 | /// protocol behind a view. Specify the ``ShapeStyle`` that's used to 265 | /// fill the shape. For example, you can create a ``Path`` that outlines 266 | /// a trapezoid: 267 | /// 268 | /// let trapezoid = Path { path in 269 | /// path.move(to: .zero) 270 | /// path.addLine(to: CGPoint(x: 90, y: 0)) 271 | /// path.addLine(to: CGPoint(x: 80, y: 50)) 272 | /// path.addLine(to: CGPoint(x: 10, y: 50)) 273 | /// } 274 | /// 275 | /// Then you can use that shape as a background for a ``Label``: 276 | /// 277 | /// Label("Flag", systemImage: "flag.fill") 278 | /// .padding() 279 | /// .background(.teal, in: trapezoid) 280 | /// 281 | /// The ``ShapeStyle/teal`` color fills the shape: 282 | /// 283 | /// ![A screenshot of the flag icon and the word flag inside a trapezoid; 284 | /// The trapezoid is filled with the color teal.](View-background-A) 285 | /// 286 | /// This modifier and ``View/background(_:in:fillStyle:)-20tq5`` are 287 | /// convenience methods for placing a single shape behind a view. To 288 | /// create a background with other ``View`` types --- or with a stack 289 | /// of views --- use ``View/background(alignment:content:)`` instead. 290 | /// To add a ``ShapeStyle`` as a background, use 291 | /// ``View/background(_:ignoresSafeAreaEdges:)``. 292 | /// 293 | /// - Parameters: 294 | /// - style: A ``ShapeStyle`` that SwiftUI uses to the fill the shape 295 | /// that you specify. 296 | /// - shape: An instance of a type that conforms to ``Shape`` that 297 | /// SwiftUI draws behind the view. 298 | /// - fillStyle: The ``FillStyle`` to use when drawing the shape. 299 | /// The default style uses the nonzero winding number rule and 300 | /// antialiasing. 301 | /// 302 | /// - Returns: A view with the specified shape drawn behind it. 303 | func customBackground( 304 | _ style: S, 305 | in shape: T, 306 | fillStyle: FillStyle = FillStyle() 307 | ) -> BottomSheet where S: ShapeStyle, T: Shape { 308 | self.configuration.backgroundView = AnyView( 309 | shape 310 | .fill( 311 | style, 312 | style: fillStyle 313 | ) 314 | ) 315 | self.configuration.backgroundViewID = UUID() 316 | return self 317 | } 318 | 319 | /// Sets the view's background to an insettable shape filled with a style. 320 | /// 321 | /// Use this modifier to layer a type that conforms to the 322 | /// ``InsettableShape`` protocol --- like a ``Rectangle``, ``Circle``, or 323 | /// ``Capsule`` --- behind a view. Specify the ``ShapeStyle`` that's used to 324 | /// fill the shape. For example, you can place a ``RoundedRectangle`` 325 | /// behind a ``Label``: 326 | /// 327 | /// Label("Flag", systemImage: "flag.fill") 328 | /// .padding() 329 | /// .background(.teal, in: RoundedRectangle(cornerRadius: 8)) 330 | /// 331 | /// The ``ShapeStyle/teal`` color fills the shape: 332 | /// 333 | /// ![A screenshot of the flag icon and word on a teal rectangle with 334 | /// rounded corners.](View-background-8) 335 | /// 336 | /// This modifier and ``View/background(_:in:fillStyle:)-89n7j`` are 337 | /// convenience methods for placing a single shape behind a view. To 338 | /// create a background with other ``View`` types --- or with a stack 339 | /// of views --- use ``View/background(alignment:content:)`` instead. 340 | /// To add a ``ShapeStyle`` as a background, use 341 | /// ``View/background(_:ignoresSafeAreaEdges:)``. 342 | /// 343 | /// - Parameters: 344 | /// - style: A ``ShapeStyle`` that SwiftUI uses to the fill the shape 345 | /// that you specify. 346 | /// - shape: An instance of a type that conforms to ``InsettableShape`` 347 | /// that SwiftUI draws behind the view. 348 | /// - fillStyle: The ``FillStyle`` to use when drawing the shape. 349 | /// The default style uses the nonzero winding number rule and 350 | /// antialiasing. 351 | /// 352 | /// - Returns: A view with the specified insettable shape drawn behind it. 353 | func customBackground( 354 | _ style: S, 355 | in shape: T, 356 | fillStyle: FillStyle = FillStyle() 357 | ) -> BottomSheet where S: ShapeStyle, T: InsettableShape { 358 | self.configuration.backgroundView = AnyView( 359 | shape 360 | .fill( 361 | style, 362 | style: fillStyle 363 | ) 364 | ) 365 | self.configuration.backgroundViewID = UUID() 366 | return self 367 | } 368 | } 369 | -------------------------------------------------------------------------------- /Sources/BottomSheet/BottomSheetView/BottomSheetView+HelperViews.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheetView+HelperViews.swift 3 | // 4 | // Created by Lucas Zischka. 5 | // Copyright © 2022 Lucas Zischka. All rights reserved. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | 11 | internal extension BottomSheetView { 12 | func fullScreenBackground(with geometry: GeometryProxy) -> some View { 13 | VisualEffectView(visualEffect: self.configuration.backgroundBlurMaterial) 14 | .opacity( 15 | // When `backgroundBlur` is enabled the opacity is calculated 16 | // based on the current height of the BottomSheet relative to its maximum height 17 | // Otherwise it is 0 18 | self.opacity(with: geometry) 19 | ) 20 | // Make the background fill the whole screen including safe area 21 | .frame( 22 | maxWidth: .infinity, 23 | maxHeight: .infinity 24 | ) 25 | .edgesIgnoringSafeArea(.all) 26 | // Make the background tap-able for `tapToDismiss` 27 | .contentShape(Rectangle()) 28 | .allowsHitTesting(self.configuration.isTapToDismissEnabled) 29 | .onTapGesture(perform: self.tapToDismissAction) 30 | // Make the background transition via opacity 31 | .transition(.opacity) 32 | } 33 | 34 | func bottomSheet(with geometry: GeometryProxy) -> some View { 35 | VStack( 36 | alignment: .center, 37 | spacing: 0 38 | ) { 39 | // Drag indicator on the top (iPhone and iPad not floating) 40 | if self.configuration.isResizable && self.configuration.isDragIndicatorShown && !self.isIPadFloatingOrMac { 41 | self.dragIndicator( with: geometry) 42 | } 43 | 44 | // The header an main content 45 | self.bottomSheetContent(with: geometry) 46 | 47 | // Drag indicator on the bottom (iPad floating and Mac) 48 | if self.configuration.isResizable && self.configuration.isDragIndicatorShown && self.isIPadFloatingOrMac { 49 | self.dragIndicator(with: geometry) 50 | } 51 | } 52 | // Set the height and width to its calculated values 53 | // The content should be aligned to the top on iPhone, 54 | // on iPad floating and Mac to the bottom for transition 55 | // to work correctly. Don't set height if `.dynamic...` 56 | // and currently not dragging 57 | .frame( 58 | width: self.width(with: geometry), 59 | height: self.bottomSheetPosition.isDynamic && self.translation == 0 ? nil : self.height(with: geometry), 60 | alignment: self.isIPadFloatingOrMac ? .bottom : .top 61 | ) 62 | // Clip BottomSheet for transition to work correctly for iPad and Mac 63 | .clipped() 64 | // BottomSheet background 65 | .background( 66 | self.bottomSheetBackground(with: geometry) 67 | ) 68 | // On iPad floating and Mac the BottomSheet has a padding 69 | .padding( 70 | self.isIPadFloatingOrMac ? 10 : 0 71 | ) 72 | // Add safe area top padding on iPad and Mac 73 | .padding( 74 | .top, 75 | self.topPadding 76 | ) 77 | // Make the BottomSheet transition via move 78 | .transition(.move( 79 | edge: self.isIPadFloatingOrMac ? .top : .bottom 80 | )) 81 | } 82 | 83 | func dragIndicator(with geometry: GeometryProxy) -> some View { 84 | Button( 85 | action: { 86 | self.dragIndicatorAction(with: geometry) 87 | }, 88 | label: { 89 | Capsule() 90 | // Design of the drag indicator 91 | .fill(self.configuration.dragIndicatorColor) 92 | .frame( 93 | width: 36, 94 | height: 5 95 | ) 96 | .padding( 97 | .top, 98 | 7.5 99 | ) 100 | .padding( 101 | .bottom, 102 | 7.5 103 | ) 104 | // Make the drag indicator drag-able 105 | .gesture( 106 | self.dragGesture(with: geometry) 107 | ) 108 | }) 109 | // Make it borderless for Mac 110 | .buttonStyle(.borderless) 111 | // Disable animation 112 | .transaction { transform in 113 | transform.disablesAnimations = true 114 | } 115 | } 116 | 117 | func bottomSheetContent(with geometry: GeometryProxy) -> some View { 118 | // Add ZStack to pin header content and make main content transition correctly for iPad and Mac 119 | ZStack(alignment: .top) { 120 | // BottomSheet main content 121 | if self.bottomSheetPosition.isBottom && self.translation == 0 { 122 | // In a bottom position the main content is hidden - add a Spacer to fill the height 123 | Spacer(minLength: 0) 124 | .frame(height: self.bottomPositionSpacerHeight) 125 | } else { 126 | // BottomSheet main content 127 | self.main(with: geometry) 128 | } 129 | 130 | // BottomSheet header content 131 | if self.headerContent != nil || self.configuration.isCloseButtonShown { 132 | self.header(with: geometry) 133 | } 134 | } 135 | // Reset dynamic main content height if it is hidden 136 | .onReceive(Just(self.bottomSheetPosition.isBottom)) { isBottom in 137 | if isBottom { 138 | // Main content is hidden, so the geometry reader can't update its height 139 | if self.bottomSheetPosition.isDynamic { 140 | // It is `.dynamicBottom` so the height of the main content is the bottomPositionSafeAreaHeight 141 | self.dynamicMainContentHeight = self.bottomPositionSafeAreaHeight 142 | } else { 143 | // Reset main content height when not dynamic but bottom 144 | self.dynamicMainContentHeight = 0 145 | } 146 | } 147 | } 148 | // Reset header content height if it is hidden 149 | .onReceive(Just(self.configuration.isCloseButtonShown)) { isCloseButtonShown in 150 | if self.headerContent == nil && !isCloseButtonShown { 151 | // Header content is hidden, so the geometry reader can't update its height 152 | // But we can, because when it is hidden its height is 0 153 | self.headerContentHeight = 0 154 | } 155 | } 156 | .onReceive(Just(self.headerContent)) { headerContent in 157 | if headerContent == nil && !self.configuration.isCloseButtonShown { 158 | // Header content is hidden, so the geometry reader can't update its height 159 | // But we can, because when it is hidden its height is 0 160 | self.headerContentHeight = 0 161 | } 162 | } 163 | } 164 | 165 | func main(with geometry: GeometryProxy) -> some View { 166 | // VStack to make frame workaround work 167 | VStack(alignment: .center, spacing: 0) { 168 | if self.configuration.isAppleScrollBehaviorEnabled && self.configuration.isResizable { 169 | // Content for `appleScrollBehaviour` 170 | if self.isIPadFloatingOrMac { 171 | // On iPad floating an Mac use a normal ScrollView 172 | ScrollView { 173 | self.mainContent 174 | } 175 | } else { 176 | #if !os(macOS) 177 | self.appleScrollView(with: geometry) 178 | #endif 179 | } 180 | } else { 181 | // Main content 182 | self.mainContent 183 | // Make the main content drag-able if content drag is enabled 184 | // highPriorityGesture is required to make dragging the bottom sheet work even when user starts dragging on buttons or other pressable items 185 | .highPriorityGesture( 186 | self.configuration.isContentDragEnabled && self.configuration.isResizable ? 187 | self.dragGesture(with: geometry) : nil 188 | ) 189 | } 190 | } 191 | // Get dynamic main content size 192 | .background(self.mainGeometryReader) 193 | // Align content correctly and make it use all available space to fix transition 194 | .frame( 195 | maxHeight: self.maxMainContentHeight(with: geometry), 196 | alignment: self.isIPadFloatingOrMac ? .bottom : .top 197 | ) 198 | // Clip main content so that it doesn't go beneath the header content 199 | .clipped() 200 | // Align content below header content 201 | .padding( 202 | .top, 203 | self.headerContentHeight 204 | ) 205 | // Add padding to the bottom to compensate for the keyboard (if desired) 206 | .padding( 207 | .bottom, 208 | self.mainContentBottomPadding 209 | ) 210 | // Make the main content transition via move 211 | .transition(.move( 212 | edge: self.isIPadFloatingOrMac ? .top : .bottom 213 | )) 214 | } 215 | 216 | var mainGeometryReader: some View { 217 | GeometryReader { mainGeometry in 218 | Color.clear 219 | .onReceive(Just(self.bottomSheetPosition.isDynamic)) { isDynamic in 220 | if isDynamic { 221 | if self.translation == 0 { 222 | // Update main content height when dynamic and not dragging 223 | self.dynamicMainContentHeight = mainGeometry.size.height 224 | } 225 | } else { 226 | // Reset main content height when not dynamic 227 | self.dynamicMainContentHeight = 0 228 | } 229 | } 230 | .onReceive(Just(self.configuration.isAppleScrollBehaviorEnabled)) { _ in 231 | if self.bottomSheetPosition.isDynamic { 232 | if self.translation == 0 { 233 | // Update main content height when dynamic and not dragging 234 | self.dynamicMainContentHeight = mainGeometry.size.height 235 | } 236 | } else { 237 | // Reset main content height when not dynamic 238 | self.dynamicMainContentHeight = 0 239 | } 240 | } 241 | .onReceive(Just(self.configuration.isResizable)) { _ in 242 | if self.bottomSheetPosition.isDynamic { 243 | if self.translation == 0 { 244 | // Update main content height when dynamic and not dragging 245 | self.dynamicMainContentHeight = mainGeometry.size.height 246 | } 247 | } else { 248 | // Reset main content height when not dynamic 249 | self.dynamicMainContentHeight = 0 250 | } 251 | } 252 | .onReceive(Just(self.mainContent)) { _ in 253 | if self.bottomSheetPosition.isDynamic { 254 | if self.translation == 0 { 255 | // Update main content height when dynamic and not dragging 256 | self.dynamicMainContentHeight = mainGeometry.size.height 257 | } 258 | } else { 259 | // Reset main content height when not dynamic 260 | self.dynamicMainContentHeight = 0 261 | } 262 | } 263 | } 264 | } 265 | 266 | #if !os(macOS) 267 | func appleScrollView(with geometry: GeometryProxy) -> some View { 268 | UIScrollViewWrapper( 269 | isScrollEnabled: self.$isScrollEnabled, 270 | dragState: self.$dragState 271 | ) { 272 | self.mainContent 273 | } 274 | // Make ScrollView drag-able 275 | .gesture( 276 | self.isScrollEnabled ? nil : self.appleScrollViewDragGesture(with: geometry) 277 | ) 278 | } 279 | #endif 280 | 281 | func header(with geometry: GeometryProxy) -> some View { 282 | HStack( 283 | alignment: .top, 284 | spacing: 0 285 | ) { 286 | // Header content 287 | if let headerContent = self.headerContent { 288 | headerContent 289 | // Add Padding when header is a title 290 | .padding( 291 | self.isTitleAsHeaderContent ? [ 292 | .leading, 293 | .trailing, 294 | .bottom 295 | ] : [] 296 | ) 297 | // Only add top padding if no drag indicator and header is a title 298 | .padding( 299 | (!self.configuration.isDragIndicatorShown || !self.configuration.isResizable) && 300 | self.isTitleAsHeaderContent ? .top : [] 301 | ) 302 | } 303 | 304 | Spacer(minLength: 0) 305 | 306 | // Close button 307 | if self.configuration.isCloseButtonShown { 308 | self.closeButton 309 | // Add padding to close button 310 | .padding([ 311 | .trailing, 312 | .bottom 313 | ]) 314 | // Only add top padding if no drag indicator 315 | .padding( 316 | (!self.configuration.isDragIndicatorShown || !self.configuration.isResizable) || 317 | self.isIPadFloatingOrMac ? .top : [] 318 | ) 319 | } 320 | } 321 | // Get header content size 322 | .background(self.headerGeometryReader) 323 | // Make the header drag-able 324 | .gesture( 325 | self.configuration.isResizable ? self.dragGesture(with: geometry) : nil 326 | ) 327 | } 328 | 329 | var closeButton: some View { 330 | Button(action: self.closeSheet) { 331 | Image( 332 | "xmark.circle.fill", 333 | bundle: Bundle.module 334 | ) 335 | // Design of the close button 336 | .resizable() 337 | .renderingMode(.template) 338 | .foregroundColor(.tertiaryLabel) 339 | .scaledToFit() 340 | .frame( 341 | width: 30, 342 | height: 30 343 | ) 344 | } 345 | // Make it borderless for Mac 346 | .buttonStyle(.borderless) 347 | } 348 | 349 | var headerGeometryReader: some View { 350 | GeometryReader { headerGeometry in 351 | Color.clear 352 | .onReceive(Just(self.headerContent)) { _ in 353 | self.headerContentHeight = headerGeometry.size.height 354 | } 355 | .onReceive(Just(self.configuration.isCloseButtonShown)) { _ in 356 | self.headerContentHeight = headerGeometry.size.height 357 | } 358 | .onReceive(Just(self.configuration.isDragIndicatorShown)) { _ in 359 | self.headerContentHeight = headerGeometry.size.height 360 | } 361 | .onReceive(Just(self.configuration.isResizable)) { _ in 362 | self.headerContentHeight = headerGeometry.size.height 363 | } 364 | } 365 | } 366 | 367 | func bottomSheetBackground(with geometry: GeometryProxy) -> some View { 368 | Group { 369 | // Use custom BottomSheet background 370 | if let backgroundView = self.configuration.backgroundView { 371 | backgroundView 372 | } else { 373 | // Default BottomSheet background 374 | VisualEffectView(visualEffect: .system) 375 | // Add corner radius to BottomSheet background 376 | // On iPhone and iPad not floating only to the top corners, 377 | // on iPad floating and Mac to all corners 378 | .cornerRadius( 379 | 10, 380 | corners: self.isIPadFloatingOrMac ? .allCorners : [ 381 | .topRight, 382 | .topLeft 383 | ] 384 | ) 385 | } 386 | } 387 | // Make the background drag-able 388 | .gesture( 389 | self.configuration.isResizable ? self.dragGesture(with: geometry) : nil 390 | ) 391 | } 392 | 393 | var mainContentBottomPadding: CGFloat { 394 | if !self.isIPadFloatingOrMac && self.configuration.accountForKeyboardHeight { 395 | #if !os(macOS) 396 | return keyboardHeight.value 397 | #else 398 | // Should not be reached 399 | return 0 400 | #endif 401 | } else { 402 | return 0 403 | } 404 | } 405 | } 406 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BottomSheet 2 | 3 | [![SwiftPM compatible](https://img.shields.io/badge/SwiftPM-compatible-brightgreen.svg)](https://swift.org/package-manager/) 4 | [![GitHub version](https://img.shields.io/github/v/release/lucaszischka/BottomSheet?sort=semver)](https://github.com/lucaszischka/BottomSheet/releases) 5 | [![CocoaPods compatible](https://img.shields.io/badge/CocoaPods-compatible-brightgreen)](http://cocoapods.org/) 6 | [![CocoaPods version](https://img.shields.io/cocoapods/v/BottomSheetSwiftUI.svg)](https://cocoapods.org/pods/BottomSheetSwiftUI) 7 | [![License](https://img.shields.io/github/license/lucaszischka/BottomSheet)](https://github.com/lucaszischka/BottomSheet/blob/main/LICENSE.txt) 8 | [![Issues](https://img.shields.io/github/issues/lucaszischka/BottomSheet)](https://github.com/lucaszischka/BottomSheet/issues) 9 | 10 | A sliding sheet from the bottom of the screen with custom states build with SwiftUI. 11 | 12 | # Version 3 is out now! 13 | Please look [here](https://github.com/lucaszischka/BottomSheet/pull/79) and read the README for more information on the changes. 14 | 15 | ## Why 16 | 17 | There have been many different attempts to recreate the BottomSheet from Apple Maps, Shortcuts and Apple Music, because Apple unfortunately does not provide it in their SDK. 18 | (*Update: It was more or less added in iOS 16*) 19 | 20 | However, most previous attempts share a common problem: The **height does not change** in the different states. Thus, the BottomSheet is always the same size (e.g. 800px) and thus remains 800px, even if you only see e.g. 400px - the rest is **inaccessible** unless you pull the BottomSheet up to the very top. 21 | 22 | There are also many implementations out there that **only have 2 states** - **not 3** like e.g. Apple Maps. 23 | 24 | ### Features 25 | - Very **easy to use** 26 | - Build in **header/title** (see [Parameters](#Parameters)) 27 | - Many view modifiers for **customisation** (see [Modifiers](#Modifiers)) 28 | - Fully customisable States (**any number of states at any height**) (see [BottomSheetPosition](#BottomSheetPosition)) 29 | - States can have the height of their content, absolute pixel values or percentages of the screen height (see [BottomSheetPosition](#BottomSheetPosition)) 30 | - Support for **SearchBar** in the header 31 | - It works with `ScrollView`, `List` and **every** other view 32 | - Can hide the content when in `...Bottom` position like Apple does 33 | - Imbedded `.appleScrollBehaviour()` modifier, to replicate Apple's ScrollView behaviour 34 | - Completely animated 35 | - And much more... 36 | 37 | ## Requirements 38 | 39 | - iOS 13, macCatalyst 13, macOS 10.15 40 | - Swift 5.5 41 | - Xcode 12 42 | 43 | ## Installation 44 | 45 | ### Swift Package Manager 46 | 47 | The preferred way of installing BottomSheet is via the [Swift Package Manager](https://swift.org/package-manager/). 48 | 49 | >Xcode 11 integrates with libSwiftPM to provide support for iOS, watchOS, and tvOS platforms. 50 | 51 | 1. In Xcode, open your project and navigate to **File** → **Add Packages** 52 | 2. Paste the repository URL (`https://github.com/lucaszischka/BottomSheet`) and click **Next**. 53 | 3. For **Rules**, select **Up to Next Major Version**. 54 | 4. Click **Add Package**. 55 | 56 | ### CocoaPods 57 | 58 | BottomSheet is available through [CocoaPods](https://cocoapods.org). To install it, simply add the following line to your Podfile: 59 | 60 | ```ruby 61 | pod 'BottomSheetSwiftUI' 62 | ``` 63 | 64 | Now run `pod install` in the Terminal to install this dependency. 65 | 66 | ## Usage 67 | 68 | **WARNING:** 69 | This is Sample Code for visualisation where and how to use, without a working initializer. Please see [Examples](#Examples) for working code. 70 | 71 | BottomSheet is similar to the built-in Sheet: 72 | 73 | ```swift 74 | struct ContentView: View { 75 | 76 | @State var bottomSheetPosition: BottomSheetPosition = .middle //1 77 | 78 | var body: some View { 79 | 80 | Map() //2 81 | .bottomSheet() //3 82 | } 83 | } 84 | ``` 85 | 86 | `//1` The current State of the BottomSheet. 87 | - For more information about the possible positions see [BottomSheetPosition](#BottomSheetPosition). 88 | - If you don't want the BottomSheet to be drag-able and the state to be switchable, you can use the `.isResizable(false)` modifier. 89 | 90 | `//2` The view which the BottomSheet overlays. 91 | - **Important:** If you want to overlay a `TabBar` or a `NavigationView`, you need to add the BottomSheet on a higher level. 92 | 93 | `//3` This is how you add the BottomSheet - easy right? 94 | 95 | ## Parameters 96 | 97 | ### Title as Header Content 98 | 99 | ```swift 100 | .bottomSheet( 101 | bottomSheetPosition: Binding, 102 | switchablePositions: [BottomSheetPosition], 103 | title: String?, 104 | content: () -> MContent 105 | ) 106 | ``` 107 | 108 | `bottomSheetPosition`: A binding that holds the current position/state of the BottomSheet. 109 | - If you don't want the BottomSheet to be drag-able and the state to be switchable, you can use the `.isResizable(false)` modifier. 110 | - For more information about the possible positions see [BottomSheetPosition](#BottomSheetPosition). 111 | 112 | `switchablePositions`: An array that contains the positions/states of the BottomSheet. 113 | - Only the positions/states contained in the array can be switched into (via tapping the drag indicator or swiping the BottomSheet). 114 | - For more information about the possible positions see [BottomSheetPosition](#BottomSheetPosition). 115 | 116 | `title`: A `String` that is displayed as title. 117 | - You can use a view that is used as header content instead. 118 | 119 | `content`: A view that is used as main content for the BottomSheet. 120 | 121 | ### Custom Header Content 122 | 123 | ```swift 124 | .bottomSheet( 125 | bottomSheetPosition: Binding, 126 | switchablePositions: [BottomSheetPosition], 127 | headerContent: () -> HContent?, 128 | mainContent: () -> MContent 129 | ) 130 | ``` 131 | 132 | `bottomSheetPosition`: A binding that holds the current position/state of the BottomSheet. 133 | - If you don't want the BottomSheet to be drag-able and the state to be switchable, you can use the `.isResizable(false)` modifier. 134 | - For more information about the possible positions see [BottomSheetPosition](#BottomSheetPosition). 135 | 136 | `switchablePositions`: An array that contains the positions/states of the BottomSheet. 137 | - Only the positions/states contained in the array can be switched into (via tapping the drag indicator or swiping the BottomSheet). 138 | - For more information about the possible positions see [BottomSheetPosition](#BottomSheetPosition). 139 | 140 | `headerContent`: A view that is used as header content for the BottomSheet. 141 | - You can use a `String` that is displayed as title instead. 142 | 143 | `mainContent`: A view that is used as main content for the BottomSheet. 144 | 145 | ## Modifiers 146 | 147 | The ViewModifiers are used to customise the look and feel of the BottomSheet. 148 | 149 | `.enableAccountingForKeyboardHeight(Bool)`: Adds padding to the bottom of the main content when the keyboard appears so all of the main content is visible. 150 | - If the height of the sheet is smaller than the height of the keyboard, this modifier will not make the content visible. 151 | - This modifier is not available on Mac, because it would not make sense there. 152 | 153 | `.enableAppleScrollBehavior(Bool)`: Packs the mainContent into a ScrollView. 154 | - Behaviour on the iPhone: 155 | - The ScrollView is only enabled (scrollable) when the BottomSheet is in a `...Top` position. 156 | - If the offset of the ScrollView becomes less than or equal to 0, the BottomSheet is pulled down instead of scrolling. 157 | - In every other position the BottomSheet will be dragged instead 158 | - This behaviour is not active on Mac and iPad, because it would not make sense there. 159 | - Please note, that this feature has sometimes weird flickering, when the content of the ScrollView is smaller than itself. If you have experience with UIKit and UIScrollViews, you are welcome to open a pull request to fix this. 160 | 161 | `.enableBackgroundBlur(Bool)`: Adds a fullscreen blur layer below the BottomSheet. 162 | - The opacity of the layer is proportional to the height of the BottomSheet. 163 | - The material can be changed using the `.backgroundBlurMaterial()` modifier. 164 | 165 | `.backgroundBlurMaterial(VisualEffect)`: Changes the material of the blur layer. 166 | - Changing the material does not affect whether the blur layer is shown. 167 | - To toggle the blur layer please use the `.enableBackgroundBlur()` modifier. 168 | 169 | `.showCloseButton(Bool)`: Adds a close button to the headerContent on the trailing side. 170 | - To perform a custom action when the BottomSheet is closed (not only via the close button), please use the `.onDismiss()` option. 171 | 172 | `.enableContentDrag(Bool)`: Makes it possible to resize the BottomSheet by dragging the mainContent. 173 | - Due to imitations in the SwiftUI framework, this option has no effect or even makes the BottomSheet glitch if the mainContent is packed into a ScrollView or a List. 174 | 175 | `.customAnimation(Animation?)`: Applies the given animation to the BottomSheet when any value changes. 176 | 177 | `.customBackground(...)`: Changes the background of the BottomSheet. 178 | - This works exactly like the native SwiftUI `.background(...)` modifier. 179 | - Using offset or shadow may break the hiding transition. 180 | 181 | `.onDragChanged((DragGesture.Value) -> Void)`: Adds an action to perform when the gesture’s value changes. 182 | 183 | `.onDragEnded((DragGesture.Value))`: Adds an action to perform when the gesture ends. 184 | 185 | `.dragPositionSwitchAction((GeometryProxy, DragGesture.Value) -> Void)`: Replaces the action that will be performed when the user drags the sheet down. 186 | - The `GeometryProxy` and `DragGesture.Value` parameter can be used for calculations. 187 | - You need to switch the positions, account for the reversed drag direction on iPad and Mac and dismiss the keyboard yourself. 188 | - Also the `swipeToDismiss` and `flickThrough` features are triggered via this method. By replacing it, you will need to handle both yourself. 189 | - The `GeometryProxy`'s height contains the bottom safe area inserts on iPhone. 190 | - The `GeometryProxy`'s height contains the top safe area inserts on iPad and Mac. 191 | 192 | `.showDragIndicator(Bool)`: Adds a drag indicator to the BottomSheet. 193 | - On iPhone it is centered above the headerContent. 194 | - On Mac and iPad it is centered above the mainContent, 195 | - To change the color of the drag indicator please use the `.dragIndicatorColor()` modifier. 196 | 197 | `.dragIndicatorColor(Color)`: Changes the color of the drag indicator. 198 | - Changing the color does not affect whether the drag indicator is shown. 199 | - To toggle the drag indicator please use the `.showDragIndicator()` modifier. 200 | 201 | `.dragIndicatorAction((GeometryProxy) -> Void)`: Replaces the action that will be performed when the drag indicator is tapped. 202 | - The `GeometryProxy` parameter can be used for calculations. 203 | - You need to switch the positions and dismiss the keyboard yourself. 204 | - The `GeometryProxy`'s height contains the bottom safe area inserts on iPhone. 205 | - The `GeometryProxy`'s height contains the top safe area inserts on iPad and Mac. 206 | 207 | `.enableFlickThrough(Bool)`: Makes it possible to switch directly to the top or bottom position by long swiping. 208 | 209 | `.enableFloatingIPadSheet(Bool)`: Makes it possible to make the sheet appear like on iPhone. 210 | 211 | `.onDismiss(() -> Void)`: A action that will be performed when the BottomSheet is dismissed. 212 | - Please note that when you dismiss the BottomSheet yourself, by setting the bottomSheetPosition to .hidden, the action will not be called. 213 | 214 | `.isResizable(Bool)`: Makes it possible to resize the BottomSheet. 215 | - When disabled the drag indicator disappears. 216 | 217 | `.sheetWidth(BottomSheetWidth)`: Makes it possible to configure a custom sheet width. 218 | - Can be relative through `BottomSheetWidth.relative(x)`. 219 | - Can be absolute through `BottomSheetWidth.absolute(x)`. 220 | - Set to `BottomSheetWidth.platformDefault` to let the library decide the width. 221 | 222 | `.enableSwipeToDismiss(Bool)`: Makes it possible to dismiss the BottomSheet by long swiping. 223 | 224 | `.enableTapToDismiss(Bool)`: Makes it possible to dismiss the BottomSheet by tapping somewhere else. 225 | 226 | `.customThreshold(Double)`: Sets a custom threshold which determines, when to trigger swipe to dismiss or flick through. 227 | - The threshold must be positive and higher than 10% (0.1). 228 | - Changing the threshold does not affect whether either option is enabled. 229 | - The default threshold is 30% (0.3). 230 | 231 | 232 | ## BottomSheetPosition 233 | 234 | The `BottomSheetPosition` enum holds all states you can switch into. 235 | There are 3 mayor types: 236 | - `.dynamic...`, where the height of the BottomSheet is equal to its content height 237 | - `.relative...`, where the height of the BottomSheet is a percentage of the screen height 238 | - `.absolute...`, where the height of the BottomSheet is a pixel value 239 | 240 | You can combine those types as much as you want. 241 | You can also use multiple instances of one case (for example `.relative(0.4)` and `.relative(0.6)`). 242 | 243 | The positions/states in detail: 244 | ```swift 245 | /// The state where the BottomSheet is hidden. 246 | case hidden 247 | 248 | /// The state where only the headerContent is visible. 249 | case dynamicBottom 250 | 251 | /// The state where the height of the BottomSheet is equal to its content size. 252 | /// Only makes sense for views that don't take all available space (like ScrollVIew, Color, ...). 253 | case dynamic 254 | 255 | /// The state where the height of the BottomSheet is equal to its content size. 256 | /// It functions as top position for appleScrollBehaviour, 257 | /// although it doesn't make much sense to use it with dynamic. 258 | /// Only makes sense for views that don't take all available space (like ScrollVIew, Color, ...). 259 | case dynamicTop 260 | 261 | /// The state where only the headerContent is visible. 262 | /// The height of the BottomSheet is x%. 263 | /// Only values between 0 and 1 make sense. 264 | /// Instead of 0 please use `.hidden`. 265 | case relativeBottom(CGFloat) 266 | 267 | /// The state where the height of the BottomSheet is equal to x%. 268 | /// Only values between 0 and 1 make sense. 269 | /// Instead of 0 please use `.hidden`. 270 | case relative(CGFloat) 271 | 272 | /// The state where the height of the BottomSheet is equal to x%. 273 | /// It functions as top position for appleScrollBehaviour. 274 | /// Only values between 0 and 1 make sense. 275 | /// Instead of 0 please use `.hidden`. 276 | case relativeTop(CGFloat) 277 | 278 | /// The state where only the headerContent is visible 279 | /// The height of the BottomSheet is x. 280 | /// Only values above 0 make sense. 281 | /// Instead of 0 please use `.hidden`. 282 | case absoluteBottom(CGFloat) 283 | 284 | /// The state where the height of the BottomSheet is equal to x. 285 | /// Only values above 0 make sense. 286 | /// Instead of 0 please use `.hidden`. 287 | case absolute(CGFloat) 288 | 289 | /// The state where the height of the BottomSheet is equal to x. 290 | /// It functions as top position for appleScrollBehaviour. 291 | /// Only values above 0 make sense. 292 | /// Instead of 0 please use `.hidden`. 293 | case absoluteTop(CGFloat) 294 | ``` 295 | 296 | ## Examples 297 | 298 | **PLEASE NOTE:** When installed via Cocoapods, please keep in mind that the pod is called `BottomSheetSwiftUI` and not `BottomSheet`; so please use `import BottomSheetSwiftUI` instead. 299 | 300 | ### Book Detail View 301 | 302 | This BottomSheet shows additional information about a book. 303 | You can close it by swiping it away, by tapping on the background or the close button. 304 | The drag indicator is hidden. 305 | The content can be used for resizing the sheet. 306 | 307 | 308 | 309 |
310 | Source Code 311 | 312 | ```swift 313 | import SwiftUI 314 | import BottomSheet 315 | 316 | struct BookDetailView: View { 317 | @State var bottomSheetPosition: BottomSheetPosition = .absolute(325) 318 | 319 | let backgroundColors: [Color] = [Color(red: 0.2, green: 0.85, blue: 0.7), Color(red: 0.13, green: 0.55, blue: 0.45)] 320 | let readMoreColors: [Color] = [Color(red: 0.70, green: 0.22, blue: 0.22), Color(red: 1, green: 0.32, blue: 0.32)] 321 | let bookmarkColors: [Color] = [Color(red: 0.28, green: 0.28, blue: 0.53), Color(red: 0.44, green: 0.44, blue: 0.83)] 322 | 323 | var body: some View { 324 | //A green gradient as a background that ignores the safe area. 325 | LinearGradient(gradient: Gradient(colors: self.backgroundColors), startPoint: .topLeading, endPoint: .bottomTrailing) 326 | .edgesIgnoringSafeArea(.all) 327 | 328 | .bottomSheet(bottomSheetPosition: self.$bottomSheetPosition, switchablePositions: [ 329 | .dynamicBottom, 330 | .absolute(325) 331 | ], headerContent: { 332 | //The name of the book as the heading and the author as the subtitle with a divider. 333 | VStack(alignment: .leading) { 334 | Text("Wuthering Heights") 335 | .font(.title).bold() 336 | 337 | Text("by Emily Brontë") 338 | .font(.subheadline).foregroundColor(.secondary) 339 | 340 | Divider() 341 | .padding(.trailing, -30) 342 | } 343 | .padding([.top, .leading]) 344 | }) { 345 | //A short introduction to the book, with a "Read More" button and a "Bookmark" button. 346 | VStack(spacing: 0) { 347 | Text("This tumultuous tale of life in a bleak farmhouse on the Yorkshire moors is a popular set text for GCSE and A-level English study, but away from the demands of the classroom it’s easier to enjoy its drama and intensity. Populated largely by characters whose inability to control their own emotions...") 348 | .fixedSize(horizontal: false, vertical: true) 349 | 350 | HStack { 351 | Button(action: {}, label: { 352 | Text("Read More") 353 | .padding(.horizontal) 354 | }) 355 | .buttonStyle(BookButton(colors: self.readMoreColors)).clipShape(Capsule()) 356 | 357 | Spacer() 358 | 359 | Button(action: {}, label: { 360 | Image(systemName: "bookmark") 361 | }) 362 | .buttonStyle(BookButton(colors: self.bookmarkColors)).clipShape(Circle()) 363 | } 364 | .padding(.top) 365 | 366 | Spacer(minLength: 0) 367 | } 368 | .padding([.horizontal, .top]) 369 | } 370 | .showDragIndicator(false) 371 | .enableContentDrag() 372 | .showCloseButton() 373 | .enableSwipeToDismiss() 374 | .enableTapToDismiss() 375 | } 376 | } 377 | 378 | //The gradient ButtonStyle. 379 | struct BookButton: ButtonStyle { 380 | 381 | let colors: [Color] 382 | 383 | func makeBody(configuration: Configuration) -> some View { 384 | configuration.label 385 | .font(.headline) 386 | .foregroundColor(.white) 387 | .padding() 388 | .background(LinearGradient(gradient: Gradient(colors: self.colors), startPoint: .topLeading, endPoint: .bottomTrailing)) 389 | } 390 | } 391 | ``` 392 |
393 | 394 | ### Word Search View 395 | 396 | This BottomSheet shows nouns which can be filtered by searching. 397 | It adapts the scrolling behaviour of apple, so that you can only scroll the `ScrollView` in the `.top` position (else the BottomSheet gets dragged); on iPad and Mac this behaviour is not present and a normal ScrollView is used. 398 | The higher the BottomSheet is dragged, the more blurry the background becomes (with the BlurEffect .systemDark) to move the focus to the BottomSheet. 399 | 400 | 401 | 402 |
403 | Source Code 404 | 405 | ```swift 406 | import SwiftUI 407 | import BottomSheet 408 | 409 | struct WordSearchView: View { 410 | 411 | @State var bottomSheetPosition: BottomSheetPosition = .relative(0.4) 412 | @State var searchText: String = "" 413 | 414 | let backgroundColors: [Color] = [Color(red: 0.28, green: 0.28, blue: 0.53), Color(red: 1, green: 0.69, blue: 0.26)] 415 | let words: [String] = ["birthday", "pancake", "expansion", "brick", "bushes", "coal", "calendar", "home", "pig", "bath", "reading", "cellar", "knot", "year", "ink"] 416 | 417 | var filteredWords: [String] { 418 | self.words.filter({ $0.contains(self.searchText.lowercased()) || self.searchText.isEmpty }) 419 | } 420 | 421 | 422 | var body: some View { 423 | //A green gradient as a background that ignores the safe area. 424 | LinearGradient(gradient: Gradient(colors: self.backgroundColors), startPoint: .topLeading, endPoint: .bottomTrailing) 425 | .edgesIgnoringSafeArea(.all) 426 | 427 | .bottomSheet(bottomSheetPosition: self.$bottomSheetPosition, switchablePositions: [ 428 | .relativeBottom(0.125), 429 | .relative(0.4), 430 | .relativeTop(0.975) 431 | ], headerContent: { 432 | //A SearchBar as headerContent. 433 | HStack { 434 | Image(systemName: "magnifyingglass") 435 | TextField("Search", text: self.$searchText) 436 | } 437 | .foregroundColor(Color(UIColor.secondaryLabel)) 438 | .padding(.vertical, 8) 439 | .padding(.horizontal, 5) 440 | .background(RoundedRectangle(cornerRadius: 10).fill(Color(UIColor.quaternaryLabel))) 441 | .padding([.horizontal, .bottom]) 442 | //When you tap the SearchBar, the BottomSheet moves to the .top position to make room for the keyboard. 443 | .onTapGesture { 444 | self.bottomSheetPosition = .relativeTop(0.975) 445 | } 446 | }) { 447 | //The list of nouns that will be filtered by the searchText. 448 | ForEach(self.filteredWords, id: \.self) { word in 449 | Text(word) 450 | .font(.title) 451 | .padding([.leading, .bottom]) 452 | .frame(maxWidth: .infinity, alignment: .leading) 453 | } 454 | .frame(maxWidth: .infinity, alignment: .leading) 455 | .transition(.opacity) 456 | .animation(.easeInOut, value: self.filteredWords) 457 | .padding(.top) 458 | } 459 | .enableAppleScrollBehavior() 460 | .enableBackgroundBlur() 461 | .backgroundBlurMaterial(.systemDark) 462 | } 463 | } 464 | ``` 465 |
466 | 467 | ### Artist Songs View 468 | 469 | This BottomSheet shows the most popular songs by an artist. 470 | It has a custom animation and color for the drag indicator and the background, as well as it deactivates the bottom position behaviour and uses a custom corner radius and shadow. 471 | 472 | 473 | 474 |
475 | Source Code 476 | 477 | ```swift 478 | import SwiftUI 479 | import BottomSheet 480 | 481 | struct ArtistSongsView: View { 482 | 483 | @State var bottomSheetPosition: BottomSheetPosition = .relative(0.4) 484 | 485 | let backgroundColors: [Color] = [Color(red: 0.17, green: 0.17, blue: 0.33), Color(red: 0.80, green: 0.38, blue: 0.2)] 486 | let songs: [String] = ["One Dance (feat. Wizkid & Kyla)", "God's Plan", "SICKO MODE", "In My Feelings", "Work (feat. Drake)", "Nice For What", "Hotline Bling", "Too Good (feat. Rihanna)", "Life Is Good (feat. Drake)"] 487 | 488 | var body: some View { 489 | //A green gradient as a background that ignores the safe area. 490 | LinearGradient(gradient: Gradient(colors: self.backgroundColors), startPoint: .topLeading, endPoint: .bottomTrailing) 491 | .edgesIgnoringSafeArea(.all) 492 | 493 | .bottomSheet(bottomSheetPosition: self.$bottomSheetPosition, switchablePositions: [ 494 | .relative(0.125), 495 | .relative(0.4), 496 | .relativeTop(0.975) 497 | ], title: "Drake") { 498 | //The list of the most popular songs of the artist. 499 | ScrollView { 500 | ForEach(self.songs, id: \.self) { song in 501 | Text(song) 502 | .frame(maxWidth: .infinity, alignment: .leading) 503 | .padding([.leading, .bottom]) 504 | } 505 | } 506 | } 507 | .customAnimation(.linear.speed(0.4)) 508 | .dragIndicatorColor(Color(red: 0.17, green: 0.17, blue: 0.33)) 509 | .customBackground( 510 | Color.black 511 | .cornerRadius(30) 512 | .shadow(color: .white, radius: 10, x: 0, y: 0) 513 | ) 514 | .foregroundColor(.white) 515 | // Adding the shadow here does not break the hiding transition, but the shadow may gets added to your other views too 516 | // .shadow(color: .white, radius: 10, x: 0, y: 0) 517 | } 518 | } 519 | ``` 520 |
521 | 522 | ## Test project 523 | A project to test the BottomSheet can be found [here](https://github.com/lucaszischka/BottomSheetTests). 524 | This project is used by me to test new features and to reproduce bugs, but can also be used very well as a demo project. 525 | 526 | ## Contributing 527 | 528 | BottomSheet welcomes contributions in the form of GitHub issues and pull-requests. 529 | Please check [the Discussions](https://github.com/lucaszischka/BottomSheet/discussions) before opening an issue or pull request. 530 | 531 | ## License 532 | 533 | BottomSheet is available under the MIT license. See [the LICENSE file](LICENSE.txt) for more information. 534 | 535 | ## Credits 536 | 537 | BottomSheet is a project of [@lucaszischka](https://github.com/lucaszischka). 538 | --------------------------------------------------------------------------------