lucaszischka / BottomSheet

A sliding Sheet from the bottom of the Screen with 3 States build with SwiftUI.
MIT License
1.02k stars 138 forks source link
bottomsheet hacktoberfest swift swift5 swiftui xcode

BottomSheet

SwiftPM compatible GitHub version CocoaPods compatible CocoaPods version License Issues

A sliding sheet from the bottom of the screen with custom states build with SwiftUI.

Version 3 is out now!

Please look here and read the README for more information on the changes.

Why

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. (Update: It was more or less added in iOS 16)

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.

There are also many implementations out there that only have 2 states - not 3 like e.g. Apple Maps.

Features

Requirements

Installation

Swift Package Manager

The preferred way of installing BottomSheet is via the Swift Package Manager.

Xcode 11 integrates with libSwiftPM to provide support for iOS, watchOS, and tvOS platforms.

  1. In Xcode, open your project and navigate to FileAdd Packages
  2. Paste the repository URL (https://github.com/lucaszischka/BottomSheet) and click Next.
  3. For Rules, select Up to Next Major Version.
  4. Click Add Package.

CocoaPods

BottomSheet is available through CocoaPods. To install it, simply add the following line to your Podfile:

pod 'BottomSheetSwiftUI'

Now run pod install in the Terminal to install this dependency.

Usage

WARNING: This is Sample Code for visualisation where and how to use, without a working initializer. Please see Examples for working code.

BottomSheet is similar to the built-in Sheet:

struct ContentView: View {

    @State var bottomSheetPosition: BottomSheetPosition = .middle //1

    var body: some View {

        Map() //2
            .bottomSheet() //3
    }
}

//1 The current State of the BottomSheet.

//2 The view which the BottomSheet overlays.

//3 This is how you add the BottomSheet - easy right?

Parameters

Title as Header Content

.bottomSheet(
    bottomSheetPosition: Binding<BottomSheetPosition>,
    switchablePositions: [BottomSheetPosition],
    title: String?,
    content: () -> MContent
)

bottomSheetPosition: A binding that holds the current position/state of the BottomSheet.

switchablePositions: An array that contains the positions/states of the BottomSheet.

title: A String that is displayed as title.

content: A view that is used as main content for the BottomSheet.

Custom Header Content

.bottomSheet(
    bottomSheetPosition: Binding<BottomSheetPosition>,
    switchablePositions: [BottomSheetPosition],
    headerContent: () -> HContent?,
    mainContent: () -> MContent
)

bottomSheetPosition: A binding that holds the current position/state of the BottomSheet.

switchablePositions: An array that contains the positions/states of the BottomSheet.

headerContent: A view that is used as header content for the BottomSheet.

mainContent: A view that is used as main content for the BottomSheet.

Modifiers

The ViewModifiers are used to customise the look and feel of the BottomSheet.

.enableAccountingForKeyboardHeight(Bool): Adds padding to the bottom of the main content when the keyboard appears so all of the main content is visible.

.enableAppleScrollBehavior(Bool): Packs the mainContent into a ScrollView.

.enableBackgroundBlur(Bool): Adds a fullscreen blur layer below the BottomSheet.

.backgroundBlurMaterial(VisualEffect): Changes the material of the blur layer.

.showCloseButton(Bool): Adds a close button to the headerContent on the trailing side.

.enableContentDrag(Bool): Makes it possible to resize the BottomSheet by dragging the mainContent.

.customAnimation(Animation?): Applies the given animation to the BottomSheet when any value changes.

.customBackground(...): Changes the background of the BottomSheet.

.onDragChanged((DragGesture.Value) -> Void): Adds an action to perform when the gesture’s value changes.

.onDragEnded((DragGesture.Value)): Adds an action to perform when the gesture ends.

.dragPositionSwitchAction((GeometryProxy, DragGesture.Value) -> Void): Replaces the action that will be performed when the user drags the sheet down.

.showDragIndicator(Bool): Adds a drag indicator to the BottomSheet.

.dragIndicatorColor(Color): Changes the color of the drag indicator.

.dragIndicatorAction((GeometryProxy) -> Void): Replaces the action that will be performed when the drag indicator is tapped.

.enableFlickThrough(Bool): Makes it possible to switch directly to the top or bottom position by long swiping.

.enableFloatingIPadSheet(Bool): Makes it possible to make the sheet appear like on iPhone.

.onDismiss(() -> Void): A action that will be performed when the BottomSheet is dismissed.

.isResizable(Bool): Makes it possible to resize the BottomSheet.

.sheetWidth(BottomSheetWidth): Makes it possible to configure a custom sheet width.

.enableSwipeToDismiss(Bool): Makes it possible to dismiss the BottomSheet by long swiping.

.enableTapToDismiss(Bool): Makes it possible to dismiss the BottomSheet by tapping somewhere else.

.customThreshold(Double): Sets a custom threshold which determines, when to trigger swipe to dismiss or flick through.

BottomSheetPosition

The BottomSheetPosition enum holds all states you can switch into. There are 3 mayor types:

You can combine those types as much as you want. You can also use multiple instances of one case (for example .relative(0.4) and .relative(0.6)).

The positions/states in detail:

/// The state where the BottomSheet is hidden.
case hidden

/// The state where only the headerContent is visible.
case dynamicBottom

/// The state where the height of the BottomSheet is equal to its content size.
/// Only makes sense for views that don't take all available space (like ScrollVIew, Color, ...).
case dynamic

/// The state where the height of the BottomSheet is equal to its content size.
/// It functions as top position for appleScrollBehaviour,
/// although it doesn't make much sense to use it with dynamic.
/// Only makes sense for views that don't take all available space (like ScrollVIew, Color, ...).
case dynamicTop

/// The state where only the headerContent is visible.
/// The height of the BottomSheet is x%.
/// Only values between 0 and 1 make sense.
/// Instead of 0 please use `.hidden`.
case relativeBottom(CGFloat)

/// The state where the height of the BottomSheet is equal to x%.
/// Only values between 0 and 1 make sense.
/// Instead of 0 please use `.hidden`.
case relative(CGFloat)

/// The state where the height of the BottomSheet is equal to x%.
/// It functions as top position for appleScrollBehaviour.
/// Only values between 0 and 1 make sense.
/// Instead of 0 please use `.hidden`.
case relativeTop(CGFloat)

/// The state where only the headerContent is visible
/// The height of the BottomSheet is x.
/// Only values above 0 make sense.
/// Instead of 0 please use `.hidden`.
case absoluteBottom(CGFloat)

/// The state where the height of the BottomSheet is equal to x.
/// Only values above 0 make sense.
/// Instead of 0 please use `.hidden`.
case absolute(CGFloat)

/// The state where the height of the BottomSheet is equal to x.
/// It functions as top position for appleScrollBehaviour.
/// Only values above 0 make sense.
/// Instead of 0 please use `.hidden`.
case absoluteTop(CGFloat)

Examples

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.

Book Detail View

This BottomSheet shows additional information about a book. You can close it by swiping it away, by tapping on the background or the close button. The drag indicator is hidden. The content can be used for resizing the sheet.

Source Code ```swift import SwiftUI import BottomSheet struct BookDetailView: View { @State var bottomSheetPosition: BottomSheetPosition = .absolute(325) let backgroundColors: [Color] = [Color(red: 0.2, green: 0.85, blue: 0.7), Color(red: 0.13, green: 0.55, blue: 0.45)] let readMoreColors: [Color] = [Color(red: 0.70, green: 0.22, blue: 0.22), Color(red: 1, green: 0.32, blue: 0.32)] let bookmarkColors: [Color] = [Color(red: 0.28, green: 0.28, blue: 0.53), Color(red: 0.44, green: 0.44, blue: 0.83)] var body: some View { //A green gradient as a background that ignores the safe area. LinearGradient(gradient: Gradient(colors: self.backgroundColors), startPoint: .topLeading, endPoint: .bottomTrailing) .edgesIgnoringSafeArea(.all) .bottomSheet(bottomSheetPosition: self.$bottomSheetPosition, switchablePositions: [ .dynamicBottom, .absolute(325) ], headerContent: { //The name of the book as the heading and the author as the subtitle with a divider. VStack(alignment: .leading) { Text("Wuthering Heights") .font(.title).bold() Text("by Emily Brontë") .font(.subheadline).foregroundColor(.secondary) Divider() .padding(.trailing, -30) } .padding([.top, .leading]) }) { //A short introduction to the book, with a "Read More" button and a "Bookmark" button. VStack(spacing: 0) { 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...") .fixedSize(horizontal: false, vertical: true) HStack { Button(action: {}, label: { Text("Read More") .padding(.horizontal) }) .buttonStyle(BookButton(colors: self.readMoreColors)).clipShape(Capsule()) Spacer() Button(action: {}, label: { Image(systemName: "bookmark") }) .buttonStyle(BookButton(colors: self.bookmarkColors)).clipShape(Circle()) } .padding(.top) Spacer(minLength: 0) } .padding([.horizontal, .top]) } .showDragIndicator(false) .enableContentDrag() .showCloseButton() .enableSwipeToDismiss() .enableTapToDismiss() } } //The gradient ButtonStyle. struct BookButton: ButtonStyle { let colors: [Color] func makeBody(configuration: Configuration) -> some View { configuration.label .font(.headline) .foregroundColor(.white) .padding() .background(LinearGradient(gradient: Gradient(colors: self.colors), startPoint: .topLeading, endPoint: .bottomTrailing)) } } ```

Word Search View

This BottomSheet shows nouns which can be filtered by searching. 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. The higher the BottomSheet is dragged, the more blurry the background becomes (with the BlurEffect .systemDark) to move the focus to the BottomSheet.

Source Code ```swift import SwiftUI import BottomSheet struct WordSearchView: View { @State var bottomSheetPosition: BottomSheetPosition = .relative(0.4) @State var searchText: String = "" let backgroundColors: [Color] = [Color(red: 0.28, green: 0.28, blue: 0.53), Color(red: 1, green: 0.69, blue: 0.26)] let words: [String] = ["birthday", "pancake", "expansion", "brick", "bushes", "coal", "calendar", "home", "pig", "bath", "reading", "cellar", "knot", "year", "ink"] var filteredWords: [String] { self.words.filter({ $0.contains(self.searchText.lowercased()) || self.searchText.isEmpty }) } var body: some View { //A green gradient as a background that ignores the safe area. LinearGradient(gradient: Gradient(colors: self.backgroundColors), startPoint: .topLeading, endPoint: .bottomTrailing) .edgesIgnoringSafeArea(.all) .bottomSheet(bottomSheetPosition: self.$bottomSheetPosition, switchablePositions: [ .relativeBottom(0.125), .relative(0.4), .relativeTop(0.975) ], headerContent: { //A SearchBar as headerContent. HStack { Image(systemName: "magnifyingglass") TextField("Search", text: self.$searchText) } .foregroundColor(Color(UIColor.secondaryLabel)) .padding(.vertical, 8) .padding(.horizontal, 5) .background(RoundedRectangle(cornerRadius: 10).fill(Color(UIColor.quaternaryLabel))) .padding([.horizontal, .bottom]) //When you tap the SearchBar, the BottomSheet moves to the .top position to make room for the keyboard. .onTapGesture { self.bottomSheetPosition = .relativeTop(0.975) } }) { //The list of nouns that will be filtered by the searchText. ForEach(self.filteredWords, id: \.self) { word in Text(word) .font(.title) .padding([.leading, .bottom]) .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxWidth: .infinity, alignment: .leading) .transition(.opacity) .animation(.easeInOut, value: self.filteredWords) .padding(.top) } .enableAppleScrollBehavior() .enableBackgroundBlur() .backgroundBlurMaterial(.systemDark) } } ```

Artist Songs View

This BottomSheet shows the most popular songs by an artist. 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.

Source Code ```swift import SwiftUI import BottomSheet struct ArtistSongsView: View { @State var bottomSheetPosition: BottomSheetPosition = .relative(0.4) let backgroundColors: [Color] = [Color(red: 0.17, green: 0.17, blue: 0.33), Color(red: 0.80, green: 0.38, blue: 0.2)] 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)"] var body: some View { //A green gradient as a background that ignores the safe area. LinearGradient(gradient: Gradient(colors: self.backgroundColors), startPoint: .topLeading, endPoint: .bottomTrailing) .edgesIgnoringSafeArea(.all) .bottomSheet(bottomSheetPosition: self.$bottomSheetPosition, switchablePositions: [ .relative(0.125), .relative(0.4), .relativeTop(0.975) ], title: "Drake") { //The list of the most popular songs of the artist. ScrollView { ForEach(self.songs, id: \.self) { song in Text(song) .frame(maxWidth: .infinity, alignment: .leading) .padding([.leading, .bottom]) } } } .customAnimation(.linear.speed(0.4)) .dragIndicatorColor(Color(red: 0.17, green: 0.17, blue: 0.33)) .customBackground( Color.black .cornerRadius(30) .shadow(color: .white, radius: 10, x: 0, y: 0) ) .foregroundColor(.white) // Adding the shadow here does not break the hiding transition, but the shadow may gets added to your other views too // .shadow(color: .white, radius: 10, x: 0, y: 0) } } ```

Test project

A project to test the BottomSheet can be found here. 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.

Contributing

BottomSheet welcomes contributions in the form of GitHub issues and pull-requests. Please check the Discussions before opening an issue or pull request.

License

BottomSheet is available under the MIT license. See the LICENSE file for more information.

Credits

BottomSheet is a project of @lucaszischka.