├── .gitignore ├── .idea ├── .gitignore ├── .name ├── appInsightsSettings.xml ├── artifacts │ └── compose_bottomsheet_material3_desktop.xml ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── compiler.xml ├── deploymentTargetDropDown.xml ├── deploymentTargetSelector.xml ├── gradle.xml ├── inspectionProfiles │ └── Project_Default.xml ├── kotlinc.xml ├── migrations.xml ├── misc.xml └── vcs.xml ├── README.md ├── advanced-bottomsheet-material3 ├── .gitignore ├── build.gradle.kts ├── consumer-rules.pro ├── proguard-rules.pro └── src │ └── androidMain │ ├── AndroidManifest.xml │ └── kotlin │ └── io │ └── morfly │ └── compose │ └── bottomsheet │ └── material3 │ ├── AnchoredDraggable.kt │ ├── AnchoredDraggableExtensions.kt │ ├── BottomSheetDefaults.kt │ ├── BottomSheetScaffold.kt │ └── BottomSheetState.kt ├── androidApp ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── kotlin │ └── io │ │ └── morfly │ │ └── bottomsheet │ │ └── sample │ │ ├── MainActivity.kt │ │ ├── MenuScreen.kt │ │ ├── Navigation.kt │ │ ├── bottomsheet │ │ ├── CustomDraggableDemoScreen.kt │ │ ├── CustomDraggableSubcomposeDemoScreen.kt │ │ ├── CustomFinalizedDemoScreen.kt │ │ ├── OfficialMaterial3DemoScreen.kt │ │ ├── SheetValue.kt │ │ └── common │ │ │ ├── BottomSheetContent.kt │ │ │ ├── BottomSheetNestedScrollConnection.kt │ │ │ └── MapScreenContent.kt │ │ └── theme │ │ ├── Color.kt │ │ ├── Theme.kt │ │ └── Type.kt │ └── res │ ├── drawable │ ├── ic_launcher_background.xml │ └── ic_launcher_foreground.xml │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-mdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xxhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xxxhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── raw │ ├── map_style.json │ └── map_style_dark.json │ ├── values │ ├── colors.xml │ ├── strings.xml │ └── themes.xml │ └── xml │ ├── backup_rules.xml │ └── data_extraction_rules.xml ├── build-tools ├── conventions │ ├── build.gradle.kts │ └── src │ │ └── main │ │ └── kotlin │ │ ├── MavenPublishPlugin.kt │ │ └── io │ │ └── morfly │ │ └── buildtools │ │ ├── ConventionPlugin.kt │ │ └── GradleDslExtensions.kt ├── gradle.properties └── settings.gradle.kts ├── build.gradle.kts ├── demos └── demo_cover.png ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── local.defaults.properties └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | secrets.properties 17 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | MultiState-BottomSheet -------------------------------------------------------------------------------- /.idea/appInsightsSettings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 25 | 26 | -------------------------------------------------------------------------------- /.idea/artifacts/compose_bottomsheet_material3_desktop.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | $PROJECT_DIR$/compose-bottomsheet-material3/build/libs 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 119 | 120 | 122 | 123 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/deploymentTargetDropDown.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /.idea/deploymentTargetSelector.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 33 | 34 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 42 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/migrations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

Advanced Bottom Sheet for Compose

3 | 4 |

5 | Apache 2.0 license 6 | Maven Central 7 |


8 | 9 |

10 | Advanced Bottom Sheet provides an implementation of a Material3 Standard Bottom Sheet component for Compose with flexible configuration abilities. 11 |


12 | 13 | 14 | ![Bottom sheet demo](demos/demo_cover.png) 15 | 16 | The purpose of this repository is to lift the limitations of the original Material 3 bottom sheet by providing a more flexible API for configuring bottom sheet states. With **Advanced Bottom Sheet** you can implement more sophisticated use cases for your designs that rely on bottom sheets. 17 | 18 | ## Installation 19 | [![Maven Central](https://img.shields.io/maven-central/v/io.morfly.compose/advanced-bottomsheet-material3.svg?label=Maven%20Central)](https://search.maven.org/search?q=g:%22io.morfly.compose%22%20AND%20a:%22advanced-bottomsheet-material3%22) 20 | 21 | #### Gradle 22 | Add the dependency below to the `build.gradle.kts` file of your module. The **Advanced Bottom Sheet** is compatible with [Compose Material3](https://developer.android.com/jetpack/androidx/releases/compose-material3). 23 | ```kotlin 24 | dependencies { 25 | implementation("io.morfly.compose:advanced-bottomsheet-material3:") 26 | } 27 | ``` 28 | 29 | ## How to use 30 | Advanced Bottom Sheet follows the API of `BottomSheetScaffold` component from the official Material3 implementation as close as possible while adding advanced configuration abilities for bottom sheets. 31 | 32 | Folow the following 3 steps to implement a bottom sheet in your app. 33 | 34 | #### Step 1 35 | Define an `enum class` that represents the values (states) of your bottom sheet. 36 | 37 | ```kotlin 38 | enum class SheetValue { Collapsed, PartiallyExpanded, Expanded } 39 | ``` 40 | 41 | #### Step 2 42 | Create an instance of a `BottomSheetState` using `rememberBottomSheetState` function. 43 | 44 | ```kotlin 45 | val sheetState = rememberBottomSheetState( 46 | initialValue = SheetValue.PartiallyExpanded, 47 | defineValues = { 48 | // Bottom sheet height is 100 dp. 49 | SheetValue.Collapsed at height(100.dp) 50 | // Bottom sheet offset is 60%, meaning it takes 40% of the screen. 51 | SheetValue.PartiallyExpanded at offset(percent = 60) 52 | // Bottom sheet height is equal to its content height. 53 | SheetValue.Expanded at contentHeight 54 | } 55 | ) 56 | ``` 57 | 58 | Use `defineValues` lambda to configure bottom sheet values by mapping them to corresponding positions using `height`, `offset` or `contentHeight` and specify the `initialValue` of the bottom sheet. 59 | 60 | Check [bottom sheet values](#bottom-sheet-values) section to learn more. 61 | 62 | #### Step 3 63 | 64 | 65 | ```kotlin 66 | val scaffoldState = rememberBottomSheetScaffoldState(sheetState) 67 | 68 | BottomSheetScaffold( 69 | scaffoldState = scaffoldState, 70 | sheetContent = { 71 | // Bottom sheet content 72 | }, 73 | content = { 74 | // Screen content 75 | } 76 | ) 77 | ``` 78 | 79 | ### Comparing with Google's implementation 80 | This project mitigates 2 constraints of the original Material 3 bottom sheet implementation from Google. 81 | - **More than 2 expanded states**. The original implementation allows the maximum of 2 expanded states. The **advanced bottom sheet** provides a convenient API for declaring as many states as you like. 82 | - **Dynamic state changes**. It also enables the ability to dynamically change the number of states whyle the bottom sheet is being used including mid-animation cases when it's being dragged. 83 | 84 | ### Bottom sheet values 85 | You can have as many bottom sheet values as you like and be able to easily configure the position of each of them. 86 | 87 | > State and value are used interchangeably in this context. 88 | 89 | You can configure the bottom sheet values during the initialization of a `BottomSheetState` instance in `defineValues` lambda. There are a few available options to configure bottom sheet values. 90 | 91 | ```kotlin 92 | val sheetState = rememberBottomSheetState( 93 | initialValue = SheetValue.PartiallyExpanded, 94 | defineValues = { 95 | SheetValue.Collapsed at height(...) 96 | SheetValue.PartiallyExpanded at offset(...) 97 | SheetValue.Expanded at contentHeight 98 | } 99 | ) 100 | ``` 101 | 102 | Define bottom sheet position using the offset from the top of the screen. 103 | 104 | - `offset(px = 200f)` — bottom sheet offset in `Float` pixels. 105 | 106 | - `offset(dp = 56.dp)` — bottom sheet offset in dp. 107 | 108 | - `offset(percent = 60)` — bottom sheet offset as percentage of the screen height. (E.g. a 60% offset means the bottom sheet takes 40% of the screen height) 109 | 110 | Define bottom sheet position using its height. 111 | 112 | - `height(px = 200f)` — bottom sheet height in `Float` pixels. 113 | 114 | - `height(dp = 56.dp)` — bottom sheet height in dp. 115 | 116 | - `height(percent = 40)` — bottom sheet height as a percentage of the screen height. (It takes 40% of the screen height in this case) 117 | 118 | Finally, use `contentHeight` if you want the bottom sheet to wrap it's content. 119 | 120 | ### Dynamically reconfigure values 121 | In some cases, you might need to add, update or remove the bottom sheet values while you're using it mid-animation. 122 | 123 | Imagine a use case when your bottom sheet has 3 values, `Collapsed`, `PartiallyExpanded` and `Expanded`. You need the mid value `PartiallyExpanded` to be present when you open the screen. However, once the user drags the bottom sheet you need to remove it so that only `Collapsed` and `PartiallyExpanded` values are present. 124 | 125 | The `BottomSheetState` instance provides a `refreshValues` function that upon calling will invoke the `defineValues` lambda again. 126 | 127 | ```kotlin 128 | var isInitialState by remember { mutableStateOf(true) } 129 | 130 | val sheetState = rememberBottomSheetState( 131 | initialValue = SheetValue.PartiallyExpanded, 132 | defineValues = { 133 | SheetValue.Collapsed at height(100.dp) 134 | if (isInitialState) { 135 | SheetValue.PartiallyExpanded at offset(percent = 60) 136 | } 137 | SheetValue.Expanded at contentHeight 138 | }, 139 | confirmValueChange = { 140 | if (isInitialState) { 141 | isInitialState = false 142 | // Invokes defineValues lambda again. 143 | refreshValues() 144 | } 145 | true 146 | } 147 | ) 148 | ``` 149 | As an example, the `confirmValueChange` lambda is invoked every time the bottom sheet value is about to be changed. This is a good place to update the variable that impacts the bottom sheet configuration. 150 | 151 | ### Observing bottom sheet state 152 | You can easily observe in realtime the position and the dimensions of the bottom sheet. 153 | 154 | A common use case is when your bottom sheet is displayed on top of a map. You might need to adjust the map UI controls or comply with the [Terms of Service](https://developers.google.com/maps/documentation/places/android-sdk/policies#logo) and display the Google logo while the bottom sheet is being dragged. 155 | 156 | ```kotlin 157 | val sheetState = rememberBottomSheetState(...) 158 | val scaffoldState = rememberBottomSheetScaffoldState(sheetState) 159 | 160 | BottomSheetScaffold( 161 | scaffoldState = scaffoldState, 162 | sheetContent = { ... }, 163 | content = { 164 | // Observe the height of the visible part of the bottom sheet 165 | // while its being dragged. 166 | val bottomPadding by remember { 167 | derivedStateOf { sheetState.requireSheetVisibleHeightDp() } 168 | } 169 | 170 | val cameraPositionState = rememberCameraPositionState() 171 | GoogleMap( 172 | cameraPositionState = cameraPositionState, 173 | // Adjust the map content padding based on the current 174 | // bottom sheet height. 175 | contentPadding = remember(bottomPadding) { 176 | PaddingValues(bottom = bottomPadding) 177 | } 178 | ) 179 | }, 180 | ) 181 | ``` 182 | By using `derivedStateOf` you will get realtime updates once the bottom sheet is being dragged. Here is the list of properties of `BottomSheetState` you could observe. 183 | 184 | `offset`, `offsetDp` — bottom sheet offset from the top of the screen in pixels and dp. 185 | 186 | `layoutHeight`, `layoutHeightDp` — height of the containing layout in pixels and dp. 187 | 188 | `sheetFullHeight`, `sheetFullHeightDp` — full height of the bottom sheet including the offscreen part in pixels and dp. 189 | 190 | `sheetVisibleHeight`, `sheetVisibleHeightDp` — visible height of the bottom sheet in pixels and dp. 191 | 192 | For each of the properties above a function with `require...` prefix is available which is preferred way to retrieve these values. For instance `requireOffset()`, `requireOffsetDp()`, etc. 193 | 194 | ## License 195 | ``` 196 | Copyright 2024 morfly (Pavlo Stavytskyi). 197 | 198 | Licensed under the Apache License, Version 2.0 (the "License"); 199 | you may not use this file except in compliance with the License. 200 | You may obtain a copy of the License at 201 | 202 | https://www.apache.org/licenses/LICENSE-2.0 203 | 204 | Unless required by applicable law or agreed to in writing, software 205 | distributed under the License is distributed on an "AS IS" BASIS, 206 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 207 | See the License for the specific language governing permissions and 208 | limitations under the License. 209 | ``` 210 | -------------------------------------------------------------------------------- /advanced-bottomsheet-material3/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /advanced-bottomsheet-material3/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | 3 | plugins { 4 | alias(libs.plugins.androidLibrary) 5 | alias(libs.plugins.kotlinMultiplatform) 6 | alias(libs.plugins.jetbrainsCompose) 7 | alias(libs.plugins.mavenPublish) 8 | alias(libs.plugins.dokka) 9 | } 10 | 11 | kotlin { 12 | androidTarget { publishLibraryVariants("release") } 13 | 14 | sourceSets { 15 | val commonMain by getting { 16 | dependencies { 17 | implementation(libs.compose.material3.ver) 18 | implementation(libs.compose.ui.ver) 19 | implementation(libs.compose.ui.graphics.ver) 20 | } 21 | } 22 | 23 | val androidMain by getting { 24 | dependencies { 25 | implementation(project.dependencies.platform(libs.compose.bom)) 26 | } 27 | } 28 | } 29 | } 30 | 31 | android { 32 | namespace = "io.morfly.compose.bottomsheet.material3" 33 | compileSdk = 34 34 | 35 | defaultConfig { 36 | minSdk = 24 37 | 38 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 39 | consumerProguardFiles("consumer-rules.pro") 40 | } 41 | 42 | buildTypes { 43 | release { 44 | isMinifyEnabled = false 45 | proguardFiles( 46 | getDefaultProguardFile("proguard-android-optimize.txt"), 47 | "proguard-rules.pro" 48 | ) 49 | } 50 | } 51 | compileOptions { 52 | sourceCompatibility = JavaVersion.VERSION_1_8 53 | targetCompatibility = JavaVersion.VERSION_1_8 54 | } 55 | buildFeatures { 56 | compose = true 57 | buildConfig = false 58 | } 59 | composeOptions { 60 | kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() 61 | } 62 | } 63 | 64 | tasks.withType { 65 | kotlinOptions { 66 | jvmTarget = "1.8" 67 | } 68 | } -------------------------------------------------------------------------------- /advanced-bottomsheet-material3/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Morfly/advanced-bottomsheet-compose/b8dea9ae58bab847dc1ea83fd07077e801ec32d0/advanced-bottomsheet-material3/consumer-rules.pro -------------------------------------------------------------------------------- /advanced-bottomsheet-material3/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /advanced-bottomsheet-material3/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /advanced-bottomsheet-material3/src/androidMain/kotlin/io/morfly/compose/bottomsheet/material3/AnchoredDraggable.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.morfly.compose.bottomsheet.material3 18 | 19 | import android.annotation.SuppressLint 20 | import androidx.annotation.FloatRange 21 | import androidx.compose.animation.core.AnimationSpec 22 | import androidx.compose.animation.core.animate 23 | import androidx.compose.foundation.ExperimentalFoundationApi 24 | import androidx.compose.foundation.MutatePriority 25 | import androidx.compose.foundation.MutatorMutex 26 | import androidx.compose.foundation.gestures.DragScope 27 | import androidx.compose.foundation.gestures.DraggableState 28 | import androidx.compose.foundation.gestures.Orientation 29 | import androidx.compose.foundation.gestures.draggable 30 | import androidx.compose.foundation.interaction.MutableInteractionSource 31 | import androidx.compose.foundation.layout.offset 32 | import androidx.compose.runtime.Stable 33 | import androidx.compose.runtime.derivedStateOf 34 | import androidx.compose.runtime.getValue 35 | import androidx.compose.runtime.mutableFloatStateOf 36 | import androidx.compose.runtime.mutableStateOf 37 | import androidx.compose.runtime.saveable.Saver 38 | import androidx.compose.runtime.setValue 39 | import androidx.compose.runtime.snapshotFlow 40 | import androidx.compose.runtime.structuralEqualityPolicy 41 | import androidx.compose.ui.Modifier 42 | import kotlinx.coroutines.CancellationException 43 | import kotlinx.coroutines.CoroutineStart 44 | import kotlinx.coroutines.Job 45 | import kotlinx.coroutines.cancel 46 | import kotlinx.coroutines.coroutineScope 47 | import kotlinx.coroutines.launch 48 | import kotlin.math.abs 49 | 50 | /** 51 | * Structure that represents the anchors of a [AnchoredDraggableState]. 52 | * 53 | * See the DraggableAnchors factory method to construct drag anchors using a default implementation. 54 | */ 55 | @ExperimentalFoundationApi 56 | interface DraggableAnchors { 57 | 58 | /** 59 | * Get the anchor position for an associated [value] 60 | * 61 | * @param value The value to look up 62 | * 63 | * @return The position of the anchor, or [Float.NaN] if the anchor does not exist 64 | */ 65 | fun positionOf(value: T): Float 66 | 67 | /** 68 | * Whether there is an anchor position associated with the [value] 69 | * 70 | * @param value The value to look up 71 | * 72 | * @return true if there is an anchor for this value, false if there is no anchor for this value 73 | */ 74 | fun hasAnchorFor(value: T): Boolean 75 | 76 | /** 77 | * Find the closest anchor to the [position]. 78 | * 79 | * @param position The position to start searching from 80 | * 81 | * @return The closest anchor or null if the anchors are empty 82 | */ 83 | fun closestAnchor(position: Float): T? 84 | 85 | /** 86 | * Find the closest anchor to the [position], in the specified direction. 87 | * 88 | * @param position The position to start searching from 89 | * @param searchUpwards Whether to search upwards from the current position or downwards 90 | * 91 | * @return The closest anchor or null if the anchors are empty 92 | */ 93 | fun closestAnchor(position: Float, searchUpwards: Boolean): T? 94 | 95 | /** 96 | * The smallest anchor, or [Float.NEGATIVE_INFINITY] if the anchors are empty. 97 | */ 98 | fun minAnchor(): Float 99 | 100 | /** 101 | * The biggest anchor, or [Float.POSITIVE_INFINITY] if the anchors are empty. 102 | */ 103 | fun maxAnchor(): Float 104 | 105 | /** 106 | * The amount of anchors 107 | */ 108 | val size: Int 109 | } 110 | 111 | /** 112 | * [DraggableAnchorsConfig] stores a mutable configuration anchors, comprised of values of [T] and 113 | * corresponding [Float] positions. This [DraggableAnchorsConfig] is used to construct an immutable 114 | * [DraggableAnchors] instance later on. 115 | */ 116 | @ExperimentalFoundationApi 117 | class DraggableAnchorsConfig { 118 | 119 | internal val anchors = mutableMapOf() 120 | 121 | /** 122 | * Set the anchor position for [this] anchor. 123 | * 124 | * @param position The anchor position. 125 | */ 126 | @Suppress("BuilderSetStyle") 127 | infix fun T.at(position: Float) { 128 | anchors[this] = position 129 | } 130 | } 131 | 132 | /** 133 | * Create a new [DraggableAnchors] instance using a builder function. 134 | * 135 | * @param builder A function with a [DraggableAnchorsConfig] that offers APIs to configure anchors 136 | * @return A new [DraggableAnchors] instance with the anchor positions set by the `builder` 137 | * function. 138 | */ 139 | @ExperimentalFoundationApi 140 | fun DraggableAnchors( 141 | builder: DraggableAnchorsConfig.() -> Unit 142 | ): DraggableAnchors = MapDraggableAnchors(DraggableAnchorsConfig().apply(builder).anchors) 143 | 144 | /** 145 | * Enable drag gestures between a set of predefined values. 146 | * 147 | * When a drag is detected, the offset of the [AnchoredDraggableState] will be updated with the drag 148 | * delta. You should use this offset to move your content accordingly (see [Modifier.offset]). 149 | * When the drag ends, the offset will be animated to one of the anchors and when that anchor is 150 | * reached, the value of the [AnchoredDraggableState] will also be updated to the value 151 | * corresponding to the new anchor. 152 | * 153 | * Dragging is constrained between the minimum and maximum anchors. 154 | * 155 | * @param state The associated [AnchoredDraggableState]. 156 | * @param orientation The orientation in which the [anchoredDraggable] can be dragged. 157 | * @param enabled Whether this [anchoredDraggable] is enabled and should react to the user's input. 158 | * @param reverseDirection Whether to reverse the direction of the drag, so a top to bottom 159 | * drag will behave like bottom to top, and a left to right drag will behave like right to left. 160 | * @param interactionSource Optional [MutableInteractionSource] that will passed on to 161 | * the internal [Modifier.draggable]. 162 | * @param startDragImmediately when set to false, [draggable] will start dragging only when the 163 | * gesture crosses the touchSlop. This is useful to prevent users from "catching" an animating 164 | * widget when pressing on it. See [draggable] to learn more about startDragImmediately. 165 | */ 166 | @SuppressLint("ModifierFactoryUnreferencedReceiver") 167 | @ExperimentalFoundationApi 168 | fun Modifier.anchoredDraggable( 169 | state: AnchoredDraggableState, 170 | orientation: Orientation, 171 | enabled: Boolean = true, 172 | reverseDirection: Boolean = false, 173 | interactionSource: MutableInteractionSource? = null, 174 | startDragImmediately: Boolean = state.isAnimationRunning 175 | ) = draggable( 176 | state = state.draggableState, 177 | orientation = orientation, 178 | enabled = enabled, 179 | interactionSource = interactionSource, 180 | reverseDirection = reverseDirection, 181 | startDragImmediately = startDragImmediately, 182 | onDragStopped = { velocity -> launch { state.settle(velocity) } } 183 | ) 184 | 185 | /** 186 | * Scope used for suspending anchored drag blocks. Allows to set [AnchoredDraggableState.offset] to 187 | * a new value. 188 | * 189 | * @see [AnchoredDraggableState.anchoredDrag] to learn how to start the anchored drag and get the 190 | * access to this scope. 191 | */ 192 | @ExperimentalFoundationApi 193 | interface AnchoredDragScope { 194 | /** 195 | * Assign a new value for an offset value for [AnchoredDraggableState]. 196 | * 197 | * @param newOffset new value for [AnchoredDraggableState.offset]. 198 | * @param lastKnownVelocity last known velocity (if known) 199 | */ 200 | fun dragTo( 201 | newOffset: Float, 202 | lastKnownVelocity: Float = 0f 203 | ) 204 | } 205 | 206 | /** 207 | * State of the [anchoredDraggable] modifier. 208 | * Use the constructor overload with anchors if the anchors are defined in composition, or update 209 | * the anchors using [updateAnchors]. 210 | * 211 | * This contains necessary information about any ongoing drag or animation and provides methods 212 | * to change the state either immediately or by starting an animation. 213 | * 214 | * @param initialValue The initial value of the state. 215 | * @param positionalThreshold The positional threshold, in px, to be used when calculating the 216 | * target state while a drag is in progress and when settling after the drag ends. This is the 217 | * distance from the start of a transition. It will be, depending on the direction of the 218 | * interaction, added or subtracted from/to the origin offset. It should always be a positive value. 219 | * @param velocityThreshold The velocity threshold (in px per second) that the end velocity has to 220 | * exceed in order to animate to the next state, even if the [positionalThreshold] has not been 221 | * reached. 222 | * @param animationSpec The default animation that will be used to animate to a new state. 223 | * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change. 224 | */ 225 | @Stable 226 | @ExperimentalFoundationApi 227 | class AnchoredDraggableState( 228 | initialValue: T, 229 | internal val positionalThreshold: (totalDistance: Float) -> Float, 230 | internal val velocityThreshold: () -> Float, 231 | val animationSpec: AnimationSpec, 232 | internal val confirmValueChange: (newValue: T) -> Boolean = { true } 233 | ) { 234 | 235 | /** 236 | * Construct an [AnchoredDraggableState] instance with anchors. 237 | * 238 | * @param initialValue The initial value of the state. 239 | * @param anchors The anchors of the state. Use [updateAnchors] to update the anchors later. 240 | * @param animationSpec The default animation that will be used to animate to a new state. 241 | * @param confirmValueChange Optional callback invoked to confirm or veto a pending state 242 | * change. 243 | * @param positionalThreshold The positional threshold, in px, to be used when calculating the 244 | * target state while a drag is in progress and when settling after the drag ends. This is the 245 | * distance from the start of a transition. It will be, depending on the direction of the 246 | * interaction, added or subtracted from/to the origin offset. It should always be a positive 247 | * value. 248 | * @param velocityThreshold The velocity threshold (in px per second) that the end velocity has 249 | * to exceed in order to animate to the next state, even if the [positionalThreshold] has not 250 | * been reached. 251 | */ 252 | @ExperimentalFoundationApi 253 | constructor( 254 | initialValue: T, 255 | anchors: DraggableAnchors, 256 | positionalThreshold: (totalDistance: Float) -> Float, 257 | velocityThreshold: () -> Float, 258 | animationSpec: AnimationSpec, 259 | confirmValueChange: (newValue: T) -> Boolean = { true } 260 | ) : this( 261 | initialValue, 262 | positionalThreshold, 263 | velocityThreshold, 264 | animationSpec, 265 | confirmValueChange 266 | ) { 267 | this.anchors = anchors 268 | trySnapTo(initialValue) 269 | } 270 | 271 | private val dragMutex = MutatorMutex() 272 | 273 | internal val draggableState = object : DraggableState { 274 | 275 | private val dragScope = object : DragScope { 276 | override fun dragBy(pixels: Float) { 277 | with(anchoredDragScope) { 278 | dragTo(newOffsetForDelta(pixels)) 279 | } 280 | } 281 | } 282 | 283 | override suspend fun drag( 284 | dragPriority: MutatePriority, 285 | block: suspend DragScope.() -> Unit 286 | ) { 287 | this@AnchoredDraggableState.anchoredDrag(dragPriority) { 288 | with(dragScope) { block() } 289 | } 290 | } 291 | 292 | override fun dispatchRawDelta(delta: Float) { 293 | this@AnchoredDraggableState.dispatchRawDelta(delta) 294 | } 295 | } 296 | 297 | /** 298 | * The current value of the [AnchoredDraggableState]. 299 | */ 300 | var currentValue: T by mutableStateOf(initialValue) 301 | private set 302 | 303 | /** 304 | * The target value. This is the closest value to the current offset, taking into account 305 | * positional thresholds. If no interactions like animations or drags are in progress, this 306 | * will be the current value. 307 | */ 308 | val targetValue: T by derivedStateOf { 309 | dragTarget ?: run { 310 | val currentOffset = offset 311 | if (!currentOffset.isNaN()) { 312 | computeTarget(currentOffset, currentValue, velocity = 0f) 313 | } else currentValue 314 | } 315 | } 316 | 317 | /** 318 | * The closest value in the swipe direction from the current offset, not considering thresholds. 319 | * If an [anchoredDrag] is in progress, this will be the target of that anchoredDrag (if 320 | * specified). 321 | */ 322 | internal val closestValue: T by derivedStateOf { 323 | dragTarget ?: run { 324 | val currentOffset = offset 325 | if (!currentOffset.isNaN()) { 326 | computeTargetWithoutThresholds(currentOffset, currentValue) 327 | } else currentValue 328 | } 329 | } 330 | 331 | /** 332 | * The current offset, or [Float.NaN] if it has not been initialized yet. 333 | * 334 | * The offset will be initialized when the anchors are first set through [updateAnchors]. 335 | * 336 | * Strongly consider using [requireOffset] which will throw if the offset is read before it is 337 | * initialized. This helps catch issues early in your workflow. 338 | */ 339 | var offset: Float by mutableFloatStateOf(Float.NaN) 340 | private set 341 | 342 | /** 343 | * Require the current offset. 344 | * 345 | * @see offset 346 | * 347 | * @throws IllegalStateException If the offset has not been initialized yet 348 | */ 349 | fun requireOffset(): Float { 350 | check(!offset.isNaN()) { 351 | "The offset was read before being initialized. Did you access the offset in a phase " + 352 | "before layout, like effects or composition?" 353 | } 354 | return offset 355 | } 356 | 357 | /** 358 | * Whether an animation is currently in progress. 359 | */ 360 | val isAnimationRunning: Boolean get() = dragTarget != null 361 | 362 | /** 363 | * The fraction of the progress going from [currentValue] to [closestValue], within [0f..1f] 364 | * bounds, or 1f if the [AnchoredDraggableState] is in a settled state. 365 | */ 366 | @get:FloatRange(from = 0.0, to = 1.0) 367 | val progress: Float by derivedStateOf(structuralEqualityPolicy()) { 368 | val a = anchors.positionOf(currentValue) 369 | val b = anchors.positionOf(closestValue) 370 | val distance = abs(b - a) 371 | if (!distance.isNaN() && distance > 1e-6f) { 372 | val progress = (this.requireOffset() - a) / (b - a) 373 | // If we are very close to 0f or 1f, we round to the closest 374 | if (progress < 1e-6f) 0f else if (progress > 1 - 1e-6f) 1f else progress 375 | } else 1f 376 | } 377 | 378 | /** 379 | * The velocity of the last known animation. Gets reset to 0f when an animation completes 380 | * successfully, but does not get reset when an animation gets interrupted. 381 | * You can use this value to provide smooth reconciliation behavior when re-targeting an 382 | * animation. 383 | */ 384 | var lastVelocity: Float by mutableFloatStateOf(0f) 385 | private set 386 | 387 | private var dragTarget: T? by mutableStateOf(null) 388 | 389 | var anchors: DraggableAnchors by mutableStateOf(emptyDraggableAnchors()) 390 | // FIXME: This line was changed as compared to AOSP version of the file. 391 | // private set 392 | 393 | /** 394 | * Update the anchors. If there is no ongoing [anchoredDrag] operation, snap to the [newTarget], 395 | * otherwise restart the ongoing [anchoredDrag] operation (e.g. an animation) with the new 396 | * anchors. 397 | * 398 | * If your anchors depend on the size of the layout, updateAnchors should be called in the 399 | * layout (placement) phase, e.g. through Modifier.onSizeChanged. This ensures that the 400 | * state is set up within the same frame. 401 | * For static anchors, or anchors with different data dependencies, [updateAnchors] is safe to 402 | * be called from side effects or layout. 403 | * 404 | * @param newAnchors The new anchors. 405 | * @param newTarget The new target, by default the closest anchor or the current target if there 406 | * are no anchors. 407 | */ 408 | fun updateAnchors( 409 | newAnchors: DraggableAnchors, 410 | newTarget: T = if (!offset.isNaN()) { 411 | newAnchors.closestAnchor(offset) ?: targetValue 412 | } else targetValue 413 | ) { 414 | if (anchors != newAnchors) { 415 | anchors = newAnchors 416 | // Attempt to snap. If nobody is holding the lock, we can immediately update the offset. 417 | // If anybody is holding the lock, we send a signal to restart the ongoing work with the 418 | // updated anchors. 419 | val snapSuccessful = trySnapTo(newTarget) 420 | if (!snapSuccessful) { 421 | dragTarget = newTarget 422 | } 423 | } 424 | } 425 | 426 | /** 427 | * Find the closest anchor, taking into account the [VelocityThreshold] and 428 | * [PositionalThreshold], and settle at it with an animation. 429 | * 430 | * If the [velocity] is lower than the [VelocityThreshold], the closest anchor by distance and 431 | * [PositionalThreshold] will be the target. If the [velocity] is higher than the 432 | * [VelocityThreshold], the [PositionalThreshold] will not be considered and the next 433 | * anchor in the direction indicated by the sign of the [velocity] will be the target. 434 | */ 435 | suspend fun settle(velocity: Float) { 436 | val previousValue = this.currentValue 437 | val targetValue = computeTarget( 438 | offset = requireOffset(), 439 | currentValue = previousValue, 440 | velocity = velocity 441 | ) 442 | if (confirmValueChange(targetValue)) { 443 | animateTo(targetValue, velocity) 444 | } else { 445 | // If the user vetoed the state change, rollback to the previous state. 446 | animateTo(previousValue, velocity) 447 | } 448 | } 449 | 450 | private fun computeTarget( 451 | offset: Float, 452 | currentValue: T, 453 | velocity: Float 454 | ): T { 455 | val currentAnchors = anchors 456 | val currentAnchorPosition = currentAnchors.positionOf(currentValue) 457 | val velocityThresholdPx = velocityThreshold() 458 | return if (currentAnchorPosition == offset || currentAnchorPosition.isNaN()) { 459 | currentValue 460 | } else { 461 | if (abs(velocity) >= abs(velocityThresholdPx)) { 462 | currentAnchors.closestAnchor( 463 | offset, 464 | offset - currentAnchorPosition > 0 465 | )!! 466 | } else { 467 | val neighborAnchor = 468 | currentAnchors.closestAnchor( 469 | offset, 470 | offset - currentAnchorPosition > 0 471 | )!! 472 | val neighborAnchorPosition = currentAnchors.positionOf(neighborAnchor) 473 | val distance = abs(currentAnchorPosition - neighborAnchorPosition) 474 | val relativeThreshold = abs(positionalThreshold(distance)) 475 | val relativePosition = abs(currentAnchorPosition - offset) 476 | if (relativePosition <= relativeThreshold) currentValue else neighborAnchor 477 | } 478 | } 479 | } 480 | 481 | private fun computeTargetWithoutThresholds( 482 | offset: Float, 483 | currentValue: T, 484 | ): T { 485 | val currentAnchors = anchors 486 | val currentAnchor = currentAnchors.positionOf(currentValue) 487 | return if (currentAnchor == offset || currentAnchor.isNaN()) { 488 | currentValue 489 | } else { 490 | currentAnchors.closestAnchor( 491 | offset, 492 | offset - currentAnchor > 0 493 | ) ?: currentValue 494 | } 495 | } 496 | 497 | private val anchoredDragScope: AnchoredDragScope = object : AnchoredDragScope { 498 | override fun dragTo(newOffset: Float, lastKnownVelocity: Float) { 499 | offset = newOffset 500 | lastVelocity = lastKnownVelocity 501 | } 502 | } 503 | 504 | /** 505 | * Call this function to take control of drag logic and perform anchored drag with the latest 506 | * anchors. 507 | * 508 | * All actions that change the [offset] of this [AnchoredDraggableState] must be performed 509 | * within an [anchoredDrag] block (even if they don't call any other methods on this object) 510 | * in order to guarantee that mutual exclusion is enforced. 511 | * 512 | * If [anchoredDrag] is called from elsewhere with the [dragPriority] higher or equal to ongoing 513 | * drag, the ongoing drag will be cancelled. 514 | * 515 | * If the [anchors] change while the [block] is being executed, it will be cancelled and 516 | * re-executed with the latest anchors and target. This allows you to target the correct 517 | * state. 518 | * 519 | * @param dragPriority of the drag operation 520 | * @param block perform anchored drag given the current anchor provided 521 | */ 522 | suspend fun anchoredDrag( 523 | dragPriority: MutatePriority = MutatePriority.Default, 524 | block: suspend AnchoredDragScope.(anchors: DraggableAnchors) -> Unit 525 | ) { 526 | try { 527 | dragMutex.mutate(dragPriority) { 528 | restartable(inputs = { anchors }) { latestAnchors -> 529 | anchoredDragScope.block(latestAnchors) 530 | } 531 | } 532 | } finally { 533 | val closest = anchors.closestAnchor(offset) 534 | if (closest != null && 535 | abs(offset - anchors.positionOf(closest)) <= 0.5f && 536 | confirmValueChange.invoke(closest) 537 | ) { 538 | currentValue = closest 539 | } 540 | } 541 | } 542 | 543 | /** 544 | * Call this function to take control of drag logic and perform anchored drag with the latest 545 | * anchors and target. 546 | * 547 | * All actions that change the [offset] of this [AnchoredDraggableState] must be performed 548 | * within an [anchoredDrag] block (even if they don't call any other methods on this object) 549 | * in order to guarantee that mutual exclusion is enforced. 550 | * 551 | * This overload allows the caller to hint the target value that this [anchoredDrag] is intended 552 | * to arrive to. This will set [AnchoredDraggableState.targetValue] to provided value so 553 | * consumers can reflect it in their UIs. 554 | * 555 | * If the [anchors] or [AnchoredDraggableState.targetValue] change while the [block] is being 556 | * executed, it will be cancelled and re-executed with the latest anchors and target. This 557 | * allows you to target the correct state. 558 | * 559 | * If [anchoredDrag] is called from elsewhere with the [dragPriority] higher or equal to ongoing 560 | * drag, the ongoing drag will be cancelled. 561 | * 562 | * @param targetValue hint the target value that this [anchoredDrag] is intended to arrive to 563 | * @param dragPriority of the drag operation 564 | * @param block perform anchored drag given the current anchor provided 565 | */ 566 | suspend fun anchoredDrag( 567 | targetValue: T, 568 | dragPriority: MutatePriority = MutatePriority.Default, 569 | block: suspend AnchoredDragScope.(anchors: DraggableAnchors, targetValue: T) -> Unit 570 | ) { 571 | if (anchors.hasAnchorFor(targetValue)) { 572 | try { 573 | dragMutex.mutate(dragPriority) { 574 | dragTarget = targetValue 575 | restartable( 576 | inputs = { anchors to this@AnchoredDraggableState.targetValue } 577 | ) { (latestAnchors, latestTarget) -> 578 | anchoredDragScope.block(latestAnchors, latestTarget) 579 | } 580 | } 581 | } finally { 582 | dragTarget = null 583 | val closest = anchors.closestAnchor(offset) 584 | if (closest != null && 585 | abs(offset - anchors.positionOf(closest)) <= 0.5f && 586 | confirmValueChange.invoke(closest) 587 | ) { 588 | currentValue = closest 589 | } 590 | } 591 | } else { 592 | // Todo: b/283467401, revisit this behavior 593 | currentValue = targetValue 594 | } 595 | } 596 | 597 | internal fun newOffsetForDelta(delta: Float) = 598 | ((if (offset.isNaN()) 0f else offset) + delta) 599 | .coerceIn(anchors.minAnchor(), anchors.maxAnchor()) 600 | 601 | /** 602 | * Drag by the [delta], coerce it in the bounds and dispatch it to the [AnchoredDraggableState]. 603 | * 604 | * @return The delta the consumed by the [AnchoredDraggableState] 605 | */ 606 | fun dispatchRawDelta(delta: Float): Float { 607 | val newOffset = newOffsetForDelta(delta) 608 | val oldOffset = if (offset.isNaN()) 0f else offset 609 | offset = newOffset 610 | return newOffset - oldOffset 611 | } 612 | 613 | /** 614 | * Attempt to snap synchronously. Snapping can happen synchronously when there is no other drag 615 | * transaction like a drag or an animation is progress. If there is another interaction in 616 | * progress, the suspending [snapTo] overload needs to be used. 617 | * 618 | * @return true if the synchronous snap was successful, or false if we couldn't snap synchronous 619 | */ 620 | private fun trySnapTo(targetValue: T): Boolean = dragMutex.tryMutate { 621 | with(anchoredDragScope) { 622 | val targetOffset = anchors.positionOf(targetValue) 623 | if (!targetOffset.isNaN()) { 624 | dragTo(targetOffset) 625 | dragTarget = null 626 | } 627 | currentValue = targetValue 628 | } 629 | } 630 | 631 | companion object { 632 | /** 633 | * The default [Saver] implementation for [AnchoredDraggableState]. 634 | */ 635 | @ExperimentalFoundationApi 636 | fun Saver( 637 | animationSpec: AnimationSpec, 638 | positionalThreshold: (distance: Float) -> Float, 639 | velocityThreshold: () -> Float, 640 | confirmValueChange: (T) -> Boolean = { true }, 641 | ) = Saver, T>( 642 | save = { it.currentValue }, 643 | restore = { 644 | AnchoredDraggableState( 645 | initialValue = it, 646 | animationSpec = animationSpec, 647 | confirmValueChange = confirmValueChange, 648 | positionalThreshold = positionalThreshold, 649 | velocityThreshold = velocityThreshold 650 | ) 651 | } 652 | ) 653 | } 654 | } 655 | 656 | /** 657 | * Snap to a [targetValue] without any animation. 658 | * If the [targetValue] is not in the set of anchors, the [AnchoredDraggableState.currentValue] will 659 | * be updated to the [targetValue] without updating the offset. 660 | * 661 | * @throws CancellationException if the interaction interrupted by another interaction like a 662 | * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call. 663 | * 664 | * @param targetValue The target value of the animation 665 | */ 666 | @ExperimentalFoundationApi 667 | suspend fun AnchoredDraggableState.snapTo(targetValue: T) { 668 | anchoredDrag(targetValue = targetValue) { anchors, latestTarget -> 669 | val targetOffset = anchors.positionOf(latestTarget) 670 | if (!targetOffset.isNaN()) dragTo(targetOffset) 671 | } 672 | } 673 | 674 | /** 675 | * Animate to a [targetValue]. 676 | * If the [targetValue] is not in the set of anchors, the [AnchoredDraggableState.currentValue] will 677 | * be updated to the [targetValue] without updating the offset. 678 | * 679 | * @throws CancellationException if the interaction interrupted by another interaction like a 680 | * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call. 681 | * 682 | * @param targetValue The target value of the animation 683 | * @param velocity The velocity the animation should start with 684 | */ 685 | @ExperimentalFoundationApi 686 | suspend fun AnchoredDraggableState.animateTo( 687 | targetValue: T, 688 | velocity: Float = this.lastVelocity, 689 | ) { 690 | anchoredDrag(targetValue = targetValue) { anchors, latestTarget -> 691 | val targetOffset = anchors.positionOf(latestTarget) 692 | if (!targetOffset.isNaN()) { 693 | var prev = if (offset.isNaN()) 0f else offset 694 | animate(prev, targetOffset, velocity, animationSpec) { value, velocity -> 695 | // Our onDrag coerces the value within the bounds, but an animation may 696 | // overshoot, for example a spring animation or an overshooting interpolator 697 | // We respect the user's intention and allow the overshoot, but still use 698 | // DraggableState's drag for its mutex. 699 | dragTo(value, velocity) 700 | prev = value 701 | } 702 | } 703 | } 704 | } 705 | 706 | private class AnchoredDragFinishedSignal : CancellationException() { 707 | override fun fillInStackTrace(): Throwable { 708 | stackTrace = emptyArray() 709 | return this 710 | } 711 | } 712 | 713 | private suspend fun restartable(inputs: () -> I, block: suspend (I) -> Unit) { 714 | try { 715 | coroutineScope { 716 | var previousDrag: Job? = null 717 | snapshotFlow(inputs) 718 | .collect { latestInputs -> 719 | previousDrag?.apply { 720 | cancel(AnchoredDragFinishedSignal()) 721 | join() 722 | } 723 | previousDrag = launch(start = CoroutineStart.UNDISPATCHED) { 724 | block(latestInputs) 725 | this@coroutineScope.cancel(AnchoredDragFinishedSignal()) 726 | } 727 | } 728 | } 729 | } catch (anchoredDragFinished: AnchoredDragFinishedSignal) { 730 | // Ignored 731 | } 732 | } 733 | 734 | private fun emptyDraggableAnchors() = MapDraggableAnchors(emptyMap()) 735 | 736 | @OptIn(ExperimentalFoundationApi::class) 737 | private class MapDraggableAnchors(private val anchors: Map) : DraggableAnchors { 738 | 739 | override fun positionOf(value: T): Float = anchors[value] ?: Float.NaN 740 | override fun hasAnchorFor(value: T) = anchors.containsKey(value) 741 | 742 | override fun closestAnchor(position: Float): T? = anchors.minByOrNull { 743 | abs(position - it.value) 744 | }?.key 745 | 746 | override fun closestAnchor( 747 | position: Float, 748 | searchUpwards: Boolean 749 | ): T? { 750 | return anchors.minByOrNull { (_, anchor) -> 751 | val delta = if (searchUpwards) anchor - position else position - anchor 752 | if (delta < 0) Float.POSITIVE_INFINITY else delta 753 | }?.key 754 | } 755 | 756 | override fun minAnchor() = anchors.values.minOrNull() ?: Float.NaN 757 | 758 | override fun maxAnchor() = anchors.values.maxOrNull() ?: Float.NaN 759 | 760 | override val size: Int 761 | get() = anchors.size 762 | 763 | override fun equals(other: Any?): Boolean { 764 | if (this === other) return true 765 | if (other !is MapDraggableAnchors<*>) return false 766 | 767 | return anchors == other.anchors 768 | } 769 | 770 | override fun hashCode() = 31 * anchors.hashCode() 771 | 772 | override fun toString() = "MapDraggableAnchors($anchors)" 773 | } 774 | -------------------------------------------------------------------------------- /advanced-bottomsheet-material3/src/androidMain/kotlin/io/morfly/compose/bottomsheet/material3/AnchoredDraggableExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Pavlo Stavytskyi 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.morfly.compose.bottomsheet.material3 18 | 19 | import androidx.compose.animation.core.AnimationSpec 20 | import androidx.compose.foundation.ExperimentalFoundationApi 21 | import androidx.compose.material3.BottomSheetDefaults 22 | import androidx.compose.material3.ExperimentalMaterial3Api 23 | import androidx.compose.runtime.Composable 24 | import androidx.compose.runtime.saveable.rememberSaveable 25 | import kotlinx.coroutines.CoroutineScope 26 | import kotlinx.coroutines.launch 27 | 28 | /** 29 | * Update the anchors similarly to [AnchoredDraggableState.updateAnchors] but animates the 30 | * transition instead of snapping in case the [AnchoredDraggableState.currentValue] is not present 31 | * anymore. 32 | * 33 | * @param scope the [CoroutineScope] to be used for the animation 34 | * @param newAnchors the new anchors 35 | * @param newTarget the new target value 36 | */ 37 | @ExperimentalFoundationApi 38 | fun AnchoredDraggableState.updateAnchorsAnimated( 39 | scope: CoroutineScope, 40 | newAnchors: DraggableAnchors, 41 | newTarget: T = if (!offset.isNaN()) { 42 | newAnchors.closestAnchor(offset) ?: targetValue 43 | } else targetValue 44 | ) { 45 | if (anchors != newAnchors) { 46 | anchors = newAnchors 47 | 48 | scope.launch { animateTo(newTarget) } 49 | } 50 | } 51 | 52 | /** 53 | * Create and [rememberSaveable] an [AnchoredDraggableState]. 54 | * 55 | * @param initialValue the initial value of the state 56 | * @param positionalThreshold the positional threshold, in px, to be used when calculating the 57 | * target state while a drag is in progress and when settling after the drag ends. This is the 58 | * distance from the start of a transition. It will be, depending on the direction of the 59 | * interaction, added or subtracted from/to the origin offset. It should always be a positive value 60 | * @param velocityThreshold the velocity threshold (in px per second) that the end velocity has to 61 | * exceed in order to animate to the next state, even if the [positionalThreshold] has not been 62 | * reached 63 | * @param animationSpec the default animation that will be used to animate to a new state 64 | * @param confirmValueChange optional callback invoked to confirm or veto a pending state change 65 | */ 66 | @ExperimentalMaterial3Api 67 | @ExperimentalFoundationApi 68 | @Composable 69 | fun rememberAnchoredDraggableState( 70 | initialValue: T, 71 | positionalThreshold: (totalDistance: Float) -> Float = BottomSheetDefaults.PositionalThreshold, 72 | velocityThreshold: () -> Float = BottomSheetDefaults.VelocityThreshold, 73 | animationSpec: AnimationSpec = BottomSheetDefaults.AnimationSpec, 74 | confirmValueChange: (T) -> Boolean = { true } 75 | ) = rememberSaveable( 76 | saver = AnchoredDraggableState.Saver( 77 | animationSpec = animationSpec, 78 | positionalThreshold = positionalThreshold, 79 | velocityThreshold = velocityThreshold, 80 | confirmValueChange = confirmValueChange 81 | ) 82 | ) { 83 | AnchoredDraggableState( 84 | initialValue = initialValue, 85 | positionalThreshold = positionalThreshold, 86 | velocityThreshold = velocityThreshold, 87 | animationSpec = animationSpec, 88 | confirmValueChange = confirmValueChange 89 | ) 90 | } -------------------------------------------------------------------------------- /advanced-bottomsheet-material3/src/androidMain/kotlin/io/morfly/compose/bottomsheet/material3/BottomSheetDefaults.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Pavlo Stavytskyi 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.morfly.compose.bottomsheet.material3 18 | 19 | import androidx.compose.animation.core.AnimationSpec 20 | import androidx.compose.animation.core.SpringSpec 21 | import androidx.compose.material3.BottomSheetDefaults 22 | import androidx.compose.material3.ExperimentalMaterial3Api 23 | 24 | /** 25 | * Default value for the [BottomSheetScaffold] positional threshold used by [AnchoredDraggableState] 26 | * internally. 27 | */ 28 | @ExperimentalMaterial3Api 29 | val BottomSheetDefaults.PositionalThreshold: (totalDistance: Float) -> Float 30 | get() = { 0f } 31 | 32 | /** 33 | * Default value for the [BottomSheetScaffold] velocity threshold used by [AnchoredDraggableState] 34 | * internally. 35 | */ 36 | @ExperimentalMaterial3Api 37 | val BottomSheetDefaults.VelocityThreshold: () -> Float 38 | get() = { 0f } 39 | 40 | /** 41 | * Default value for the [BottomSheetScaffold] animation spec used by [AnchoredDraggableState] 42 | * internally. 43 | */ 44 | @ExperimentalMaterial3Api 45 | val BottomSheetDefaults.AnimationSpec: AnimationSpec 46 | get() = SpringSpec() 47 | -------------------------------------------------------------------------------- /advanced-bottomsheet-material3/src/androidMain/kotlin/io/morfly/compose/bottomsheet/material3/BottomSheetScaffold.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Pavlo Stavytskyi 3 | * Copyright 2022 The Android Open Source Project 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | package io.morfly.compose.bottomsheet.material3 19 | 20 | import androidx.annotation.IntRange 21 | import androidx.compose.foundation.ExperimentalFoundationApi 22 | import androidx.compose.foundation.gestures.Orientation 23 | import androidx.compose.foundation.layout.Box 24 | import androidx.compose.foundation.layout.Column 25 | import androidx.compose.foundation.layout.ColumnScope 26 | import androidx.compose.foundation.layout.PaddingValues 27 | import androidx.compose.foundation.layout.consumeWindowInsets 28 | import androidx.compose.foundation.layout.fillMaxWidth 29 | import androidx.compose.foundation.layout.padding 30 | import androidx.compose.foundation.layout.widthIn 31 | import androidx.compose.foundation.verticalScroll 32 | import androidx.compose.material3.BottomSheetDefaults 33 | import androidx.compose.material3.ExperimentalMaterial3Api 34 | import androidx.compose.material3.LocalContentColor 35 | import androidx.compose.material3.MaterialTheme 36 | import androidx.compose.material3.SmallTopAppBar 37 | import androidx.compose.material3.Snackbar 38 | import androidx.compose.material3.SnackbarHost 39 | import androidx.compose.material3.SnackbarHostState 40 | import androidx.compose.material3.Surface 41 | import androidx.compose.material3.contentColorFor 42 | import androidx.compose.runtime.Composable 43 | import androidx.compose.runtime.DisposableEffect 44 | import androidx.compose.runtime.SideEffect 45 | import androidx.compose.runtime.Stable 46 | import androidx.compose.runtime.remember 47 | import androidx.compose.runtime.rememberCoroutineScope 48 | import androidx.compose.ui.Alignment 49 | import androidx.compose.ui.Modifier 50 | import androidx.compose.ui.geometry.Offset 51 | import androidx.compose.ui.graphics.Color 52 | import androidx.compose.ui.graphics.Shape 53 | import androidx.compose.ui.input.nestedscroll.NestedScrollConnection 54 | import androidx.compose.ui.input.nestedscroll.NestedScrollSource 55 | import androidx.compose.ui.input.nestedscroll.nestedScroll 56 | import androidx.compose.ui.layout.SubcomposeLayout 57 | import androidx.compose.ui.layout.onSizeChanged 58 | import androidx.compose.ui.platform.LocalDensity 59 | import androidx.compose.ui.unit.Density 60 | import androidx.compose.ui.unit.Dp 61 | import androidx.compose.ui.unit.Velocity 62 | import kotlinx.coroutines.launch 63 | import kotlin.math.roundToInt 64 | 65 | /** 66 | * State of the [BottomSheetScaffold] composable. 67 | * 68 | * @param sheetState the state of the persistent bottom sheet 69 | * @param snackbarHostState the [SnackbarHostState] used to show snackbars inside the scaffold 70 | */ 71 | @ExperimentalFoundationApi 72 | @Stable 73 | class BottomSheetScaffoldState( 74 | val sheetState: BottomSheetState, 75 | val snackbarHostState: SnackbarHostState, 76 | ) 77 | 78 | /** 79 | * Create and [remember] a [BottomSheetScaffoldState]. 80 | * 81 | * @param sheetState the state of the standard bottom sheet. See [rememberBottomSheetState] 82 | * @param snackbarHostState the [SnackbarHostState] used to show snackbars inside the scaffold 83 | */ 84 | @ExperimentalFoundationApi 85 | @Composable 86 | fun rememberBottomSheetScaffoldState( 87 | sheetState: BottomSheetState, 88 | snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } 89 | ): BottomSheetScaffoldState { 90 | return remember(sheetState, snackbarHostState) { 91 | BottomSheetScaffoldState( 92 | sheetState = sheetState, 93 | snackbarHostState = snackbarHostState 94 | ) 95 | } 96 | } 97 | 98 | /** 99 | * Material Design standard bottom sheet scaffold. 100 | * 101 | * Standard bottom sheets co-exist with the screen’s main UI region and allow for simultaneously 102 | * viewing and interacting with both regions. They are commonly used to keep a feature or 103 | * secondary content visible on screen when content in main UI region is frequently scrolled or 104 | * panned. 105 | * 106 | * This component provides API to put together several material components to construct your 107 | * screen, by ensuring proper layout strategy for them and collecting necessary data so these 108 | * components will work together correctly. 109 | * 110 | * @param scaffoldState the state of the bottom sheet scaffold 111 | * @param sheetContent the content of the bottom sheet 112 | * @param modifier the [Modifier] to be applied to this scaffold 113 | * @param sheetMaxWidth [Dp] that defines what the maximum width the sheet will take. 114 | * Pass in [Dp.Unspecified] for a sheet that spans the entire screen width. 115 | * @param sheetShape the shape of the bottom sheet 116 | * @param sheetContainerColor the background color of the bottom sheet 117 | * @param sheetContentColor the preferred content color provided by the bottom sheet to its 118 | * children. Defaults to the matching content color for [sheetContainerColor], or if that is 119 | * not a color from the theme, this will keep the same content color set above the bottom sheet. 120 | * @param sheetTonalElevation the tonal elevation of the bottom sheet 121 | * @param sheetShadowElevation the shadow elevation of the bottom sheet 122 | * @param sheetDragHandle optional visual marker to pull the scaffold's bottom sheet 123 | * @param sheetSwipeEnabled whether the sheet swiping is enabled and should react to the user's 124 | * input 125 | * @param topBar top app bar of the screen, typically a [SmallTopAppBar] 126 | * @param snackbarHost component to host [Snackbar]s that are pushed to be shown via 127 | * [SnackbarHostState.showSnackbar], typically a [SnackbarHost] 128 | * @param containerColor the color used for the background of this scaffold. Use [Color.Transparent] 129 | * to have no color. 130 | * @param contentColor the preferred color for content inside this scaffold. Defaults to either the 131 | * matching content color for [containerColor], or to the current [LocalContentColor] if 132 | * [containerColor] is not a color from the theme. 133 | * @param content content of the screen. The lambda receives a [PaddingValues] that should be 134 | * applied to the content root via [Modifier.padding] and [Modifier.consumeWindowInsets] to 135 | * properly offset top and bottom bars. If using [Modifier.verticalScroll], apply this modifier to 136 | * the child of the scroll, and not on the scroll itself. 137 | */ 138 | @ExperimentalMaterial3Api 139 | @ExperimentalFoundationApi 140 | @Composable 141 | fun BottomSheetScaffold( 142 | scaffoldState: BottomSheetScaffoldState, 143 | sheetContent: @Composable ColumnScope.() -> Unit, 144 | modifier: Modifier = Modifier, 145 | sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth, 146 | sheetShape: Shape = BottomSheetDefaults.ExpandedShape, 147 | sheetContainerColor: Color = BottomSheetDefaults.ContainerColor, 148 | sheetContentColor: Color = contentColorFor(sheetContainerColor), 149 | sheetTonalElevation: Dp = BottomSheetDefaults.Elevation, 150 | sheetShadowElevation: Dp = BottomSheetDefaults.Elevation, 151 | sheetDragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, 152 | sheetSwipeEnabled: Boolean = true, 153 | topBar: @Composable (() -> Unit)? = null, 154 | snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) }, 155 | containerColor: Color = MaterialTheme.colorScheme.surface, 156 | contentColor: Color = contentColorFor(containerColor), 157 | content: @Composable (PaddingValues) -> Unit 158 | ) { 159 | val density = LocalDensity.current 160 | 161 | BottomSheetScaffoldLayout( 162 | modifier = modifier, 163 | topBar = topBar, 164 | body = content, 165 | snackbarHost = { 166 | snackbarHost(scaffoldState.snackbarHostState) 167 | }, 168 | sheetOffset = { scaffoldState.sheetState.draggableState.requireOffset() }, 169 | containerColor = containerColor, 170 | contentColor = contentColor, 171 | bottomSheet = { layoutHeight -> 172 | SideEffect { 173 | scaffoldState.sheetState.layoutHeight = layoutHeight 174 | } 175 | StandardBottomSheet( 176 | state = scaffoldState.sheetState, 177 | sheetMaxWidth = sheetMaxWidth, 178 | sheetSwipeEnabled = sheetSwipeEnabled, 179 | calculateAnchors = { sheetFullHeight -> 180 | val config = BottomSheetValuesConfig( 181 | layoutHeight = layoutHeight, 182 | sheetFullHeight = sheetFullHeight, 183 | density = density, 184 | ) 185 | scaffoldState.sheetState.defineValues(config) 186 | require(config.values.isNotEmpty()) { "No bottom sheet values provided!" } 187 | 188 | DraggableAnchors { 189 | for ((state, value) in config.values) { 190 | state at value 191 | } 192 | } 193 | }, 194 | shape = sheetShape, 195 | containerColor = sheetContainerColor, 196 | contentColor = sheetContentColor, 197 | tonalElevation = sheetTonalElevation, 198 | shadowElevation = sheetShadowElevation, 199 | dragHandle = sheetDragHandle, 200 | content = sheetContent 201 | ) 202 | }, 203 | ) 204 | } 205 | 206 | @Composable 207 | internal fun BottomSheetScaffoldLayout( 208 | modifier: Modifier, 209 | topBar: @Composable (() -> Unit)?, 210 | body: @Composable (innerPadding: PaddingValues) -> Unit, 211 | bottomSheet: @Composable (layoutHeight: Int) -> Unit, 212 | snackbarHost: @Composable () -> Unit, 213 | sheetOffset: () -> Float, 214 | containerColor: Color, 215 | contentColor: Color, 216 | ) { 217 | val density = LocalDensity.current 218 | SubcomposeLayout { constraints -> 219 | val layoutWidth = constraints.maxWidth 220 | val layoutHeight = constraints.maxHeight 221 | val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0) 222 | 223 | val sheetPlaceable = subcompose(BottomSheetScaffoldLayoutSlot.Sheet) { 224 | bottomSheet(layoutHeight) 225 | }[0].measure(looseConstraints) 226 | 227 | val topBarPlaceable = topBar?.let { 228 | subcompose(BottomSheetScaffoldLayoutSlot.TopBar, topBar).takeIf { it.isNotEmpty() }?.let { 229 | it[0].measure(looseConstraints) 230 | } 231 | } 232 | val topBarHeight = topBarPlaceable?.height ?: 0 233 | 234 | val bodyConstraints = looseConstraints.copy(maxHeight = layoutHeight) 235 | val bodyPlaceable = subcompose(BottomSheetScaffoldLayoutSlot.Body) { 236 | Surface( 237 | modifier = modifier, 238 | color = containerColor, 239 | contentColor = contentColor, 240 | ) { body(PaddingValues(top = with(density) { topBarHeight.toDp() })) } 241 | }[0].measure(bodyConstraints) 242 | 243 | val snackbarPlaceable = subcompose(BottomSheetScaffoldLayoutSlot.Snackbar, snackbarHost)[0] 244 | .measure(looseConstraints) 245 | 246 | layout(width = layoutWidth, height = layoutHeight) { 247 | val sheetOffsetY = sheetOffset().roundToInt() 248 | val sheetOffsetX = Integer.max(0, (layoutWidth - sheetPlaceable.width) / 2) 249 | 250 | val snackbarOffsetX = (layoutWidth - snackbarPlaceable.width) / 2 251 | 252 | val snackbarThreshold = minOf(snackbarPlaceable.height * 2f, layoutHeight * 0.3f) 253 | val snackbarOffsetY = if (layoutHeight - sheetOffsetY < snackbarThreshold) { 254 | sheetOffsetY - snackbarPlaceable.height 255 | } else { 256 | layoutHeight - snackbarPlaceable.height 257 | } 258 | 259 | // Placement order is important for elevation 260 | bodyPlaceable.placeRelative(0, 0) 261 | topBarPlaceable?.placeRelative(0, 0) 262 | sheetPlaceable.placeRelative(sheetOffsetX, sheetOffsetY) 263 | snackbarPlaceable.placeRelative(snackbarOffsetX, snackbarOffsetY) 264 | } 265 | } 266 | } 267 | 268 | private enum class BottomSheetScaffoldLayoutSlot { TopBar, Body, Sheet, Snackbar } 269 | 270 | @ExperimentalFoundationApi 271 | @Composable 272 | internal fun StandardBottomSheet( 273 | state: BottomSheetState, 274 | calculateAnchors: (sheetFullHeight: Int) -> DraggableAnchors, 275 | sheetMaxWidth: Dp, 276 | sheetSwipeEnabled: Boolean, 277 | shape: Shape, 278 | containerColor: Color, 279 | contentColor: Color, 280 | tonalElevation: Dp, 281 | shadowElevation: Dp, 282 | dragHandle: @Composable (() -> Unit)?, 283 | content: @Composable ColumnScope.() -> Unit 284 | ) { 285 | val scope = rememberCoroutineScope() 286 | val orientation = Orientation.Vertical 287 | 288 | DisposableEffect(state) { 289 | val onRefreshValues = fun(sheetFullHeight: Int, targetValue: T, animate: Boolean) { 290 | val newAnchors = calculateAnchors(sheetFullHeight) 291 | if (animate) { 292 | state.draggableState.updateAnchorsAnimated(scope, newAnchors, targetValue) 293 | } else { 294 | state.draggableState.updateAnchors(newAnchors, targetValue) 295 | } 296 | } 297 | 298 | state.onRefreshValues += onRefreshValues 299 | onDispose { 300 | state.onRefreshValues -= onRefreshValues 301 | } 302 | } 303 | 304 | Surface( 305 | modifier = Modifier 306 | .widthIn(max = sheetMaxWidth) 307 | .fillMaxWidth() 308 | .nestedScroll( 309 | remember(state) { 310 | BottomSheetNestedScrollConnection( 311 | draggableState = state.draggableState, 312 | orientation = orientation, 313 | onFling = { velocity -> 314 | scope.launch { state.draggableState.settle(velocity) } 315 | } 316 | ) 317 | }, 318 | ) 319 | .anchoredDraggable( 320 | state = state.draggableState, 321 | orientation = orientation, 322 | enabled = sheetSwipeEnabled, 323 | ) 324 | .onSizeChanged { sheetFullSize -> 325 | state.sheetFullHeight = sheetFullSize.height 326 | val newAnchors = calculateAnchors(sheetFullSize.height) 327 | state.draggableState.updateAnchors(newAnchors, state.targetValue) 328 | }, 329 | shape = shape, 330 | color = containerColor, 331 | contentColor = contentColor, 332 | tonalElevation = tonalElevation, 333 | shadowElevation = shadowElevation, 334 | ) { 335 | Column(Modifier.fillMaxWidth()) { 336 | if (dragHandle != null) { 337 | Box(modifier = Modifier.align(Alignment.CenterHorizontally)) { 338 | dragHandle() 339 | } 340 | } 341 | content() 342 | } 343 | } 344 | } 345 | 346 | @ExperimentalFoundationApi 347 | @Suppress("FunctionName") 348 | internal fun BottomSheetNestedScrollConnection( 349 | draggableState: AnchoredDraggableState, 350 | orientation: Orientation, 351 | onFling: (velocity: Float) -> Unit, 352 | ): NestedScrollConnection = object : NestedScrollConnection { 353 | override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { 354 | val delta = available.toFloat() 355 | return if (delta < 0 && source == NestedScrollSource.Drag) { 356 | draggableState.dispatchRawDelta(delta).toOffset() 357 | } else { 358 | Offset.Zero 359 | } 360 | } 361 | 362 | override fun onPostScroll( 363 | consumed: Offset, 364 | available: Offset, 365 | source: NestedScrollSource, 366 | ): Offset { 367 | return if (source == NestedScrollSource.Drag) { 368 | draggableState.dispatchRawDelta(available.toFloat()).toOffset() 369 | } else { 370 | Offset.Zero 371 | } 372 | } 373 | 374 | override suspend fun onPreFling(available: Velocity): Velocity { 375 | val toFling = available.toFloat() 376 | val currentOffset = draggableState.requireOffset() 377 | val minAnchor = draggableState.anchors.minAnchor() 378 | return if (toFling < 0 && currentOffset > minAnchor) { 379 | onFling(toFling) 380 | // since we go to the anchor with tween settling, consume all for the best UX 381 | available 382 | } else { 383 | Velocity.Zero 384 | } 385 | } 386 | 387 | override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { 388 | onFling(available.toFloat()) 389 | return available 390 | } 391 | 392 | private fun Float.toOffset(): Offset = Offset( 393 | x = if (orientation == Orientation.Horizontal) this else 0f, 394 | y = if (orientation == Orientation.Vertical) this else 0f, 395 | ) 396 | 397 | @JvmName("velocityToFloat") 398 | private fun Velocity.toFloat() = if (orientation == Orientation.Horizontal) x else y 399 | 400 | @JvmName("offsetToFloat") 401 | private fun Offset.toFloat(): Float = if (orientation == Orientation.Horizontal) x else y 402 | } 403 | 404 | /** 405 | * [BottomSheetValuesConfig] stores mutable configuration values for the bottom sheet comprised of 406 | * values of [T] and corresponding [Float] positions represented as offset in pixels. 407 | * 408 | * @param layoutHeight the height of the scaffold containing the sheet 409 | * @param sheetFullHeight the full height of the content of the bottom sheet including an offscreen 410 | * part 411 | * @param density the [Density] instance 412 | */ 413 | class BottomSheetValuesConfig( 414 | val layoutHeight: Int, 415 | val sheetFullHeight: Int, 416 | val density: Density, 417 | ) { 418 | /** 419 | * The height of the bottom sheet content represented as [Position]. 420 | */ 421 | val contentHeight = Position((layoutHeight - sheetFullHeight).toFloat()) 422 | 423 | /** 424 | * Collection of bottom sheet values and corresponding positions as offsets in pixels. 425 | */ 426 | val values = mutableMapOf() 427 | 428 | /** 429 | * Set the bottom sheet value and its [Position]. 430 | * @param position the bottom sheet position as offset in pixels. 431 | */ 432 | infix fun T.at(position: Position) { 433 | values[this] = maxOf(position.offsetPx, contentHeight.offsetPx) 434 | } 435 | 436 | /** 437 | * Define bottom sheet position using its offset from the top in pixels. 438 | * @param px the bottom sheet offset in pixels 439 | */ 440 | fun offset(px: Float): Position { 441 | return Position(px) 442 | } 443 | 444 | /** 445 | * Define bottom sheet position using its offset from the top in dp. 446 | * @param dp the bottom sheet offset in pixels 447 | */ 448 | fun offset(dp: Dp): Position { 449 | return Position(with(density) { dp.toPx() }) 450 | } 451 | 452 | /** 453 | * Define bottom sheet position using its offset from the top as a percentage of the layout's 454 | * height. 455 | * @param percent the bottom sheet height in percent 456 | */ 457 | fun offset(@IntRange(from = 0, to = 100) percent: Int): Position { 458 | return Position(layoutHeight * percent / 100f) 459 | } 460 | 461 | /** 462 | * Define bottom sheet position using its height in pixels. 463 | * @param px the bottom sheet height in pixels 464 | */ 465 | fun height(px: Float): Position { 466 | return Position(layoutHeight - offset(px).offsetPx) 467 | } 468 | 469 | /** 470 | * Define bottom sheet position using its height in dp. 471 | * @param dp the bottom sheet height in dp 472 | */ 473 | fun height(dp: Dp): Position { 474 | return Position(layoutHeight - offset(dp).offsetPx) 475 | } 476 | 477 | /** 478 | * Define bottom sheet position using its height as a percentage of the layout's height. 479 | * @param percent the bottom sheet height in percent 480 | */ 481 | fun height(@IntRange(from = 0, to = 100) percent: Int): Position { 482 | return Position(layoutHeight - offset(percent).offsetPx) 483 | } 484 | 485 | /** 486 | * Holder of positions of bottom sheet values. Both [offset] and [height] are represented as 487 | * offset from the top in pixels. 488 | * @param offsetPx the bottom sheet offset from the top in pixels 489 | */ 490 | @JvmInline 491 | value class Position internal constructor(val offsetPx: Float) 492 | } 493 | -------------------------------------------------------------------------------- /advanced-bottomsheet-material3/src/androidMain/kotlin/io/morfly/compose/bottomsheet/material3/BottomSheetState.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Pavlo Stavytskyi 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.morfly.compose.bottomsheet.material3 18 | 19 | import androidx.compose.animation.core.AnimationSpec 20 | import androidx.compose.foundation.ExperimentalFoundationApi 21 | import androidx.compose.material3.BottomSheetDefaults 22 | import androidx.compose.material3.ExperimentalMaterial3Api 23 | import androidx.compose.runtime.Composable 24 | import androidx.compose.runtime.LaunchedEffect 25 | import androidx.compose.runtime.Stable 26 | import androidx.compose.runtime.derivedStateOf 27 | import androidx.compose.runtime.getValue 28 | import androidx.compose.runtime.mutableFloatStateOf 29 | import androidx.compose.runtime.mutableIntStateOf 30 | import androidx.compose.runtime.mutableStateOf 31 | import androidx.compose.runtime.remember 32 | import androidx.compose.runtime.rememberCoroutineScope 33 | import androidx.compose.runtime.saveable.Saver 34 | import androidx.compose.runtime.setValue 35 | import androidx.compose.ui.platform.LocalDensity 36 | import androidx.compose.ui.unit.Density 37 | import androidx.compose.ui.unit.Dp 38 | import kotlinx.coroutines.launch 39 | 40 | /** 41 | * State of a [BottomSheetScaffold] composable. 42 | * 43 | * Manages values of the bottom sheet and transitions between them. It also provides the dimension 44 | * information about the bottom sheet. 45 | * 46 | * @param draggableState the [AnchoredDraggableState] that controls the bottom sheet values and drag 47 | * animations 48 | * @param defineValues a lambda that defines the values of the bottom sheet 49 | * @param density the [Density] instance 50 | */ 51 | @ExperimentalFoundationApi 52 | @Stable 53 | class BottomSheetState( 54 | val draggableState: AnchoredDraggableState, 55 | internal val defineValues: BottomSheetValuesConfig.() -> Unit, 56 | internal val density: Density 57 | ) { 58 | /** 59 | * Callbacks invoked by [refreshValues]. 60 | */ 61 | internal val onRefreshValues = mutableSetOf<(Int, T, Boolean) -> Unit>() 62 | 63 | /** 64 | * The values of the bottom sheet represented as [DraggableAnchors]. 65 | */ 66 | val values: DraggableAnchors get() = draggableState.anchors 67 | 68 | /** 69 | * Height of a layout containing a bottom sheet scaffold in pixels, or [Int.MAX_VALUE] if it has 70 | * not been initialized yet. 71 | * 72 | * Will be initialized during the first measurement phase of the provided sheet content. 73 | */ 74 | var layoutHeight: Int by mutableIntStateOf(Int.MAX_VALUE) 75 | internal set 76 | 77 | /** 78 | * Full height of a bottom sheet content including an offscreen part in pixels, or 79 | * [Int.MAX_VALUE] if it has not been initialized yet. 80 | * 81 | * Will be initialized during the first measurement phase of the provided sheet content. 82 | */ 83 | var sheetFullHeight: Int by mutableIntStateOf(Int.MAX_VALUE) 84 | internal set 85 | 86 | /** 87 | * Height of the visible part of a bottom sheet content in pixels. 88 | * 89 | * Will be initialized during the first measurement phase of the provided sheet content. 90 | */ 91 | val sheetVisibleHeight: Float by derivedStateOf { 92 | layoutHeight - offset 93 | } 94 | 95 | /** 96 | * The current offset of the bottom sheet in pixels, or [Float.NaN] if it has not been 97 | * initialized yet. 98 | * 99 | * Will be initialized during the first measurement phase of the provided sheet content. 100 | */ 101 | val offset: Float get() = draggableState.offset 102 | 103 | /** 104 | * The current value of the bottom sheet. 105 | */ 106 | val currentValue: T get() = draggableState.currentValue 107 | 108 | /** 109 | * The target value. This is the closest value to the current offset, taking into account 110 | * positional thresholds. If no interactions like animations or drags are in progress, this 111 | * will be the current value. 112 | */ 113 | val targetValue: T get() = draggableState.targetValue 114 | 115 | /** 116 | * Require [layoutHeight]. 117 | * 118 | * @throws IllegalStateException If the layout height has not been initialized yet 119 | */ 120 | fun requireLayoutHeight(): Int { 121 | check(layoutHeight != Int.MAX_VALUE) { 122 | "The layoutHeight was read before being initialized. Did you access the " + 123 | "layoutHeight in a phase before layout, like effects or composition?" 124 | } 125 | return layoutHeight 126 | } 127 | 128 | /** 129 | * Require [sheetFullHeight]. 130 | * 131 | * @throws IllegalStateException If the [sheetFullHeight] has not been initialized yet 132 | */ 133 | fun requireSheetFullHeight(): Int { 134 | check(sheetFullHeight != Int.MAX_VALUE) { 135 | "The sheetFullHeight was read before being initialized. Did you access the " + 136 | "sheetFullHeight in a phase before layout, like effects or composition?" 137 | } 138 | return sheetFullHeight 139 | } 140 | 141 | /** 142 | * Require [sheetVisibleHeight]. 143 | * 144 | * @throws IllegalStateException If the [sheetVisibleHeight] has not been initialized yet 145 | */ 146 | fun requireSheetVisibleHeight(): Float { 147 | check(!sheetVisibleHeight.isNaN()) { 148 | "The sheetVisibleHeight was read before being initialized. Did you access the " + 149 | "sheetVisibleHeight in a phase before layout, like effects or composition?" 150 | } 151 | return sheetVisibleHeight 152 | } 153 | 154 | /** 155 | * Require [offset]. 156 | * 157 | * @throws IllegalStateException If the [offset] has not been initialized yet 158 | */ 159 | fun requireOffset() = draggableState.requireOffset() 160 | 161 | /** 162 | * Initiate the reconfiguration of the bottom sheet values by calling the [defineValues] lambda. 163 | * 164 | * @param targetValue the target value of the bottom sheet after the update 165 | * @param animate animate the transition to a [targetValue] or snap without any animation. 166 | */ 167 | fun refreshValues( 168 | targetValue: T = this.targetValue, 169 | animate: Boolean = true 170 | ) { 171 | if (sheetFullHeight != Int.MAX_VALUE && !offset.isNaN()) { 172 | onRefreshValues.forEach { call -> call(requireSheetFullHeight(), targetValue, animate) } 173 | } 174 | } 175 | 176 | /** 177 | * Animate to a [targetValue]. 178 | * 179 | * @param targetValue The target value of the animation 180 | * @param velocity The velocity the animation should start with 181 | */ 182 | suspend fun animateTo( 183 | targetValue: T, 184 | velocity: Float = draggableState.lastVelocity, 185 | ) = draggableState.animateTo(targetValue, velocity) 186 | 187 | /** 188 | * Snap to a [targetValue] without any animation. 189 | * 190 | * @param targetValue The target value of the animation 191 | */ 192 | suspend fun snapTo( 193 | targetValue: T 194 | ) = draggableState.snapTo(targetValue) 195 | 196 | internal fun Int.toDpIfInitialized(): Dp = 197 | if (this == Int.MAX_VALUE) Dp.Unspecified else with(density) { toDp() } 198 | 199 | internal fun Float.toDpIfInitialized(): Dp = 200 | if (isNaN()) Dp.Unspecified else with(density) { toDp() } 201 | 202 | companion object { 203 | 204 | /** 205 | * The default [Saver] implementation for [BottomSheetState]. 206 | */ 207 | fun Saver( 208 | defineValues: BottomSheetValuesConfig.() -> Unit, 209 | density: Density 210 | ) = Saver, AnchoredDraggableState>( 211 | save = { it.draggableState }, 212 | restore = { draggableState -> 213 | BottomSheetState(draggableState, defineValues, density) 214 | } 215 | ) 216 | } 217 | } 218 | 219 | /** 220 | * Height of a layout containing a bottom sheet scaffold in dp, or [Dp.Unspecified] if it has 221 | * not been initialized yet. 222 | * 223 | * Will be initialized during the first measurement phase of the provided sheet content. 224 | */ 225 | @ExperimentalFoundationApi 226 | val BottomSheetState.layoutHeightDp: Dp 227 | get() = layoutHeight.toDpIfInitialized() 228 | 229 | /** 230 | * Full height of a bottom sheet content including an offscreen part in dp, or [Dp.Unspecified] 231 | * if it has not been initialized yet. 232 | * 233 | * Will be initialized during the first measurement phase of the provided sheet content. 234 | */ 235 | @ExperimentalFoundationApi 236 | val BottomSheetState.sheetFullHeightDp: Dp 237 | get() = sheetFullHeight.toDpIfInitialized() 238 | 239 | /** 240 | * Height of the visible part of a bottom sheet content in dp. 241 | * 242 | * Will be initialized during the first measurement phase of the provided sheet content. 243 | */ 244 | @ExperimentalFoundationApi 245 | val BottomSheetState.sheetVisibleHeightDp: Dp 246 | get() = sheetVisibleHeight.toDpIfInitialized() 247 | 248 | /** 249 | * The current offset of the bottom sheet in dp, or [Dp.Unspecified] if it has not been 250 | * initialized yet. 251 | * 252 | * Will be initialized during the first measurement phase of the provided sheet content. 253 | */ 254 | @ExperimentalFoundationApi 255 | val BottomSheetState.offsetDp: Dp 256 | get() = offset.toDpIfInitialized() 257 | 258 | /** 259 | * Require [layoutHeightDp]. 260 | * 261 | * @throws IllegalStateException If the [BottomSheetState.layoutHeight] has not been initialized yet 262 | */ 263 | @ExperimentalFoundationApi 264 | fun BottomSheetState.requireLayoutHeightDp(): Dp { 265 | return with(density) { requireLayoutHeight().toDp() } 266 | } 267 | 268 | /** 269 | * Require [sheetFullHeightDp]. 270 | * 271 | * @throws IllegalStateException If the [BottomSheetState.sheetFullHeight] has not been initialized 272 | * yet 273 | */ 274 | @ExperimentalFoundationApi 275 | fun BottomSheetState.requireSheetFullHeightDp(): Dp { 276 | return with(density) { requireSheetFullHeight().toDp() } 277 | } 278 | 279 | /** 280 | * Require [sheetVisibleHeightDp]. 281 | * 282 | * @throws IllegalStateException If the [BottomSheetState.sheetVisibleHeight] has not been 283 | * initialized yet 284 | */ 285 | @ExperimentalFoundationApi 286 | fun BottomSheetState.requireSheetVisibleHeightDp(): Dp { 287 | return with(density) { requireSheetVisibleHeight().toDp() } 288 | } 289 | 290 | /** 291 | * Require [offsetDp]. 292 | * 293 | * @throws IllegalStateException If the [BottomSheetState.offset] has not been initialized yet 294 | */ 295 | @ExperimentalFoundationApi 296 | fun BottomSheetState.requireOffsetDp(): Dp { 297 | return with(density) { requireOffset().toDp() } 298 | } 299 | 300 | /** 301 | * Create and [remember] a [BottomSheetState]. 302 | * 303 | * @param initialValue the initial value of the state 304 | * @param defineValues a lambda that defines the values of the bottom sheet 305 | * @param positionalThreshold the positional threshold, in px, to be used when calculating the 306 | * target state while a drag is in progress and when settling after the drag ends. This is the 307 | * distance from the start of a transition. It will be, depending on the direction of the 308 | * interaction, added or subtracted from/to the origin offset. It should always be a positive value 309 | * @param velocityThreshold the velocity threshold (in px per second) that the end velocity has to 310 | * exceed in order to animate to the next state, even if the [positionalThreshold] has not been 311 | * reached 312 | * @param animationSpec the default animation that will be used to animate to a new state 313 | * @param confirmValueChange optional callback invoked to confirm or veto a pending state change 314 | */ 315 | @ExperimentalMaterial3Api 316 | @ExperimentalFoundationApi 317 | @Composable 318 | fun rememberBottomSheetState( 319 | initialValue: T, 320 | defineValues: BottomSheetValuesConfig.() -> Unit, 321 | positionalThreshold: (totalDistance: Float) -> Float = BottomSheetDefaults.PositionalThreshold, 322 | velocityThreshold: () -> Float = BottomSheetDefaults.VelocityThreshold, 323 | animationSpec: AnimationSpec = BottomSheetDefaults.AnimationSpec, 324 | confirmValueChange: BottomSheetState.(T) -> Boolean = { true } 325 | ): BottomSheetState { 326 | lateinit var state: BottomSheetState 327 | lateinit var draggableState: AnchoredDraggableState 328 | 329 | val scope = rememberCoroutineScope() 330 | var prevSearchedUpwards by remember { mutableStateOf(null) } 331 | var prevOffset by remember { mutableFloatStateOf(Float.NaN) } 332 | 333 | draggableState = rememberAnchoredDraggableState( 334 | initialValue = initialValue, 335 | positionalThreshold = positionalThreshold, 336 | velocityThreshold = velocityThreshold, 337 | animationSpec = animationSpec, 338 | confirmValueChange = { value -> 339 | // If BottomSheetState.refreshValues is called while the bottom sheet is moving, the 340 | // underlying AnchoredDraggableState may bug out, so that it just hangs at a random 341 | // position and stops anchoring. This is a workaround to prevent that from happening. 342 | // If the new value is not in the list of anchors, the bottom sheet will be animated to 343 | // the closest anchor considering the direction of the movement. 344 | with(draggableState) { 345 | val currentOffset = requireOffset() 346 | val searchUpwards = 347 | if (prevOffset == currentOffset) prevSearchedUpwards 348 | else prevOffset < currentOffset 349 | 350 | prevSearchedUpwards = searchUpwards 351 | prevOffset = currentOffset 352 | 353 | if (!anchors.hasAnchorFor(value)) { 354 | val closest = if (searchUpwards != null) { 355 | anchors.closestAnchor(currentOffset, searchUpwards) 356 | } else { 357 | anchors.closestAnchor(currentOffset) 358 | } 359 | if (closest != null) { 360 | scope.launch { animateTo(closest) } 361 | } 362 | false 363 | } else { 364 | state.confirmValueChange(value) 365 | } 366 | } 367 | } 368 | ) 369 | 370 | LaunchedEffect(draggableState.anchors) { 371 | if (prevOffset.isNaN()) { 372 | prevOffset = with(draggableState) { anchors.positionOf(currentValue) } 373 | } 374 | } 375 | 376 | state = rememberBottomSheetState(draggableState, defineValues) 377 | return state 378 | } 379 | 380 | @ExperimentalFoundationApi 381 | @Composable 382 | internal fun rememberBottomSheetState( 383 | draggableState: AnchoredDraggableState, 384 | defineValues: BottomSheetValuesConfig.() -> Unit, 385 | ): BottomSheetState { 386 | val density = LocalDensity.current 387 | 388 | return remember(draggableState) { 389 | BottomSheetState(draggableState, defineValues, density) 390 | } 391 | } 392 | -------------------------------------------------------------------------------- /androidApp/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /androidApp/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.androidApplication) 3 | alias(libs.plugins.kotlinAndroid) 4 | alias(libs.plugins.androidMapsSecrets) 5 | } 6 | 7 | android { 8 | namespace = "io.morfly.bottomsheet.sample" 9 | compileSdk = 34 10 | 11 | defaultConfig { 12 | applicationId = "io.morfly.bottomsheet.sample" 13 | minSdk = 24 14 | targetSdk = 34 15 | versionCode = 1 16 | versionName = "1.0" 17 | 18 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 19 | vectorDrawables { 20 | useSupportLibrary = true 21 | } 22 | } 23 | 24 | buildTypes { 25 | release { 26 | isMinifyEnabled = false 27 | proguardFiles( 28 | getDefaultProguardFile("proguard-android-optimize.txt"), 29 | "proguard-rules.pro" 30 | ) 31 | signingConfig = signingConfigs.getByName("debug") 32 | } 33 | } 34 | compileOptions { 35 | sourceCompatibility = JavaVersion.VERSION_1_8 36 | targetCompatibility = JavaVersion.VERSION_1_8 37 | } 38 | kotlinOptions { 39 | jvmTarget = "1.8" 40 | } 41 | buildFeatures { 42 | compose = true 43 | } 44 | composeOptions { 45 | kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() 46 | } 47 | packaging { 48 | resources { 49 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 50 | } 51 | } 52 | } 53 | 54 | secrets { 55 | defaultPropertiesFileName = "local.defaults.properties" 56 | 57 | ignoreList.add("keyToIgnore") 58 | ignoreList.add("sdk.*") 59 | } 60 | 61 | dependencies { 62 | implementation(projects.advancedBottomsheetMaterial3) 63 | 64 | implementation(platform(libs.compose.bom)) 65 | 66 | implementation(libs.androidx.core.ktx) 67 | implementation(libs.androidx.lifecycle.runtime.ktx) 68 | implementation(libs.androidx.activity.compose) 69 | implementation(libs.compose.ui) 70 | implementation(libs.compose.ui.graphics) 71 | implementation(libs.compose.ui.tooling.preview) 72 | implementation(libs.compose.material3) 73 | implementation(libs.compose.navigation) 74 | implementation(libs.compose.coil) 75 | implementation(libs.android.maps.compose) 76 | 77 | debugImplementation(libs.compose.ui.tooling) 78 | debugImplementation(libs.compose.ui.test.manifest) 79 | } -------------------------------------------------------------------------------- /androidApp/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /androidApp/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 15 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 29 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /androidApp/src/main/kotlin/io/morfly/bottomsheet/sample/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package io.morfly.bottomsheet.sample 2 | 3 | import android.content.res.Configuration 4 | import android.os.Bundle 5 | import androidx.activity.ComponentActivity 6 | import androidx.activity.SystemBarStyle 7 | import androidx.activity.compose.setContent 8 | import androidx.activity.enableEdgeToEdge 9 | import androidx.compose.material3.Surface 10 | import io.morfly.bottomsheet.sample.theme.MultiStateBottomSheetSampleTheme 11 | 12 | class MainActivity : ComponentActivity() { 13 | 14 | override fun onCreate(savedInstanceState: Bundle?) { 15 | enableEdgeToEdgeWithUiMode() 16 | super.onCreate(savedInstanceState) 17 | setContent { 18 | MultiStateBottomSheetSampleTheme { 19 | Surface { 20 | Navigation() 21 | } 22 | } 23 | } 24 | } 25 | } 26 | 27 | private fun ComponentActivity.enableEdgeToEdgeWithUiMode() { 28 | val uiMode = resources.configuration.uiMode 29 | val isDark = (uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES 30 | 31 | val statusBarStyle = if (isDark) { 32 | SystemBarStyle.dark(android.graphics.Color.TRANSPARENT) 33 | } else { 34 | SystemBarStyle.light( 35 | android.graphics.Color.TRANSPARENT, 36 | android.graphics.Color.TRANSPARENT 37 | ) 38 | } 39 | enableEdgeToEdge(statusBarStyle = statusBarStyle) 40 | } 41 | -------------------------------------------------------------------------------- /androidApp/src/main/kotlin/io/morfly/bottomsheet/sample/MenuScreen.kt: -------------------------------------------------------------------------------- 1 | package io.morfly.bottomsheet.sample 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.foundation.layout.height 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.rememberScrollState 11 | import androidx.compose.foundation.verticalScroll 12 | import androidx.compose.material3.MaterialTheme 13 | import androidx.compose.material3.OutlinedButton 14 | import androidx.compose.material3.Text 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.unit.dp 19 | 20 | @Composable 21 | fun MenuScreen( 22 | onClick: (Destination) -> Unit, 23 | modifier: Modifier = Modifier, 24 | ) { 25 | Column( 26 | horizontalAlignment = Alignment.CenterHorizontally, 27 | modifier = modifier 28 | .background(MaterialTheme.colorScheme.surface) 29 | .padding(top = 64.dp, bottom = 16.dp) 30 | ) { 31 | Text(text = "Bottom Sheet Samples", style = MaterialTheme.typography.headlineSmall) 32 | 33 | Column( 34 | verticalArrangement = Arrangement.SpaceEvenly, 35 | horizontalAlignment = Alignment.CenterHorizontally, 36 | modifier = Modifier 37 | .fillMaxSize() 38 | .verticalScroll(rememberScrollState()) 39 | ) { 40 | Item( 41 | title = "Official Material3 Bottom Sheet", 42 | description = "Sample using original bottom sheet from material 3 library.", 43 | onClick = { onClick(Destination.Material3Demo) } 44 | ) 45 | Item( 46 | title = "Anchored Draggable", 47 | description = "Custom basic bottom sheet implementation using anchored draggable.", 48 | onClick = { onClick(Destination.CustomDraggableDemo) } 49 | ) 50 | Item( 51 | title = "Anchored Draggable + Subcompose Layout", 52 | description = "Custom bottom sheet implementation using anchored draggable and subcompose layout.", 53 | onClick = { onClick(Destination.CustomDraggableSubcomposeDemo) } 54 | ) 55 | Item( 56 | title = "Finalized Custom Bottom Sheet", 57 | description = "Sample with the finalized bottom sheet implementation using anchored draggable and subcompose layout. Provides customizable API and is available as a library in this repository.", 58 | onClick = { onClick(Destination.CustomFinalizedDemo) } 59 | ) 60 | } 61 | } 62 | } 63 | 64 | @Composable 65 | private fun Item( 66 | title: String, 67 | description: String, 68 | modifier: Modifier = Modifier, 69 | onClick: () -> Unit, 70 | ) { 71 | Column( 72 | horizontalAlignment = Alignment.CenterHorizontally, 73 | modifier = modifier 74 | .fillMaxSize() 75 | .background(MaterialTheme.colorScheme.surfaceContainerLow) 76 | .padding(16.dp) 77 | ) { 78 | OutlinedButton(onClick = onClick) { 79 | Text(title) 80 | } 81 | Spacer(Modifier.height(16.dp)) 82 | Text(text = description, style = MaterialTheme.typography.bodyLarge) 83 | } 84 | } -------------------------------------------------------------------------------- /androidApp/src/main/kotlin/io/morfly/bottomsheet/sample/Navigation.kt: -------------------------------------------------------------------------------- 1 | package io.morfly.bottomsheet.sample 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.navigation.compose.NavHost 5 | import androidx.navigation.compose.composable 6 | import androidx.navigation.compose.rememberNavController 7 | import io.morfly.bottomsheet.sample.bottomsheet.CustomFinalizedDemoScreen 8 | import io.morfly.bottomsheet.sample.bottomsheet.CustomDraggableDemoScreen 9 | import io.morfly.bottomsheet.sample.bottomsheet.OfficialMaterial3DemoScreen 10 | import io.morfly.bottomsheet.sample.bottomsheet.CustomDraggableSubcomposeDemoScreen 11 | 12 | enum class Destination { 13 | Menu, Material3Demo, CustomDraggableDemo, CustomDraggableSubcomposeDemo, CustomFinalizedDemo; 14 | } 15 | 16 | @Composable 17 | fun Navigation() { 18 | val navController = rememberNavController() 19 | 20 | NavHost(navController = navController, startDestination = Destination.Menu.name) { 21 | composable(Destination.Menu.name) { 22 | MenuScreen(onClick = { destination -> navController.navigate(destination.name) }) 23 | } 24 | composable(Destination.Material3Demo.name) { 25 | OfficialMaterial3DemoScreen() 26 | } 27 | composable(Destination.CustomDraggableDemo.name) { 28 | CustomDraggableDemoScreen() 29 | } 30 | composable(Destination.CustomDraggableSubcomposeDemo.name) { 31 | CustomDraggableSubcomposeDemoScreen() 32 | } 33 | composable(Destination.CustomFinalizedDemo.name) { 34 | CustomFinalizedDemoScreen() 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /androidApp/src/main/kotlin/io/morfly/bottomsheet/sample/bottomsheet/CustomDraggableDemoScreen.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalFoundationApi::class) 2 | 3 | package io.morfly.bottomsheet.sample.bottomsheet 4 | 5 | import androidx.compose.animation.core.SpringSpec 6 | import androidx.compose.foundation.ExperimentalFoundationApi 7 | import androidx.compose.foundation.gestures.AnchoredDraggableState 8 | import androidx.compose.foundation.gestures.DraggableAnchors 9 | import androidx.compose.foundation.gestures.Orientation 10 | import androidx.compose.foundation.gestures.anchoredDraggable 11 | import androidx.compose.foundation.layout.BoxWithConstraints 12 | import androidx.compose.foundation.layout.fillMaxWidth 13 | import androidx.compose.foundation.layout.offset 14 | import androidx.compose.foundation.layout.padding 15 | import androidx.compose.material3.Surface 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.runtime.remember 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.layout.onSizeChanged 20 | import androidx.compose.ui.platform.LocalDensity 21 | import androidx.compose.ui.unit.IntOffset 22 | import androidx.compose.ui.unit.dp 23 | import io.morfly.bottomsheet.sample.bottomsheet.common.BottomSheetContent 24 | import io.morfly.bottomsheet.sample.bottomsheet.common.MapScreenContent 25 | 26 | @Composable 27 | fun CustomDraggableDemoScreen() { 28 | val density = LocalDensity.current 29 | 30 | val state = remember { 31 | AnchoredDraggableState( 32 | initialValue = SheetValue.PartiallyExpanded, 33 | positionalThreshold = { 0f }, 34 | velocityThreshold = { 0f }, 35 | animationSpec = SpringSpec(), 36 | ) 37 | } 38 | 39 | BoxWithConstraints { 40 | val layoutHeight = constraints.maxHeight 41 | 42 | MapScreenContent() 43 | 44 | Surface( 45 | shadowElevation = 1.dp, 46 | tonalElevation = 1.dp, 47 | modifier = Modifier 48 | .fillMaxWidth() 49 | .offset { 50 | val sheetOffsetY = state.requireOffset() 51 | IntOffset(x = 0, y = sheetOffsetY.toInt()) 52 | } 53 | .anchoredDraggable( 54 | state = state, 55 | orientation = Orientation.Vertical, 56 | ) 57 | .onSizeChanged { sheetSize -> 58 | val sheetHeight = sheetSize.height 59 | val newAnchors = DraggableAnchors { 60 | with(density) { 61 | // Bottom sheet height is 100 dp. 62 | SheetValue.Collapsed at (layoutHeight - 100.dp.toPx()) 63 | // Offset is 60% which means the bottom sheet takes 40% of the screen. 64 | SheetValue.PartiallyExpanded at (layoutHeight * 0.6f) 65 | // Bottom sheet height is equal to the height of its content. 66 | SheetValue.Expanded at maxOf(layoutHeight - sheetHeight, 0).toFloat() 67 | } 68 | } 69 | state.updateAnchors(newAnchors, state.targetValue) 70 | } 71 | ) { 72 | BottomSheetContent( 73 | modifier = Modifier.padding(top = 16.dp), 74 | userScrollEnabled = false, 75 | ) 76 | } 77 | } 78 | } -------------------------------------------------------------------------------- /androidApp/src/main/kotlin/io/morfly/bottomsheet/sample/bottomsheet/CustomDraggableSubcomposeDemoScreen.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalFoundationApi::class) 2 | 3 | package io.morfly.bottomsheet.sample.bottomsheet 4 | 5 | import androidx.compose.animation.core.Spring 6 | import androidx.compose.animation.core.spring 7 | import androidx.compose.foundation.ExperimentalFoundationApi 8 | import androidx.compose.foundation.gestures.AnchoredDraggableState 9 | import androidx.compose.foundation.gestures.DraggableAnchors 10 | import androidx.compose.foundation.gestures.Orientation 11 | import androidx.compose.foundation.gestures.anchoredDraggable 12 | import androidx.compose.foundation.layout.fillMaxWidth 13 | import androidx.compose.foundation.layout.padding 14 | import androidx.compose.material3.Surface 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.runtime.remember 17 | import androidx.compose.runtime.rememberCoroutineScope 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.input.nestedscroll.nestedScroll 20 | import androidx.compose.ui.layout.SubcomposeLayout 21 | import androidx.compose.ui.layout.onSizeChanged 22 | import androidx.compose.ui.platform.LocalDensity 23 | import androidx.compose.ui.unit.dp 24 | import io.morfly.bottomsheet.sample.bottomsheet.common.BottomSheetContent 25 | import io.morfly.bottomsheet.sample.bottomsheet.common.BottomSheetNestedScrollConnection 26 | import io.morfly.bottomsheet.sample.bottomsheet.common.MapScreenContent 27 | import kotlinx.coroutines.launch 28 | import kotlin.math.roundToInt 29 | 30 | @Composable 31 | fun CustomDraggableSubcomposeDemoScreen() { 32 | val state = remember { 33 | AnchoredDraggableState( 34 | initialValue = SheetValue.PartiallyExpanded, 35 | positionalThreshold = { 0f }, 36 | velocityThreshold = { 0f }, 37 | animationSpec = spring( 38 | dampingRatio = Spring.DampingRatioNoBouncy, 39 | stiffness = Spring.StiffnessMedium, 40 | ), 41 | ) 42 | } 43 | 44 | BottomSheetScaffold( 45 | state = state, 46 | sheetContent = { 47 | BottomSheetContent(modifier = Modifier.padding(top = 16.dp)) 48 | }, 49 | content = { 50 | MapScreenContent() 51 | } 52 | ) 53 | } 54 | 55 | @Composable 56 | private fun BottomSheetScaffold( 57 | state: AnchoredDraggableState, 58 | sheetContent: @Composable () -> Unit, 59 | modifier: Modifier = Modifier, 60 | content: @Composable () -> Unit 61 | ) { 62 | SubcomposeLayout { constraints -> 63 | val layoutWidth = constraints.maxWidth 64 | val layoutHeight = constraints.maxHeight 65 | 66 | val sheetPlaceable = subcompose(slotId = "sheet") { 67 | BottomSheet( 68 | state = state, 69 | layoutHeight = layoutHeight, 70 | sheetContent = sheetContent, 71 | ) 72 | }[0].measure(constraints) 73 | 74 | val bodyPlaceable = subcompose(slotId = "body") { 75 | Surface(modifier) { 76 | content() 77 | } 78 | }[0].measure(constraints) 79 | 80 | layout(width = layoutWidth, height = layoutHeight) { 81 | val sheetOffsetY = state.requireOffset().roundToInt() 82 | val sheetOffsetX = 0 83 | 84 | bodyPlaceable.placeRelative(x = 0, y = 0) 85 | sheetPlaceable.placeRelative(sheetOffsetX, sheetOffsetY) 86 | } 87 | } 88 | } 89 | 90 | @Composable 91 | private fun BottomSheet( 92 | state: AnchoredDraggableState, 93 | layoutHeight: Int, 94 | sheetContent: @Composable () -> Unit, 95 | ) { 96 | val scope = rememberCoroutineScope() 97 | val density = LocalDensity.current 98 | 99 | Surface( 100 | shadowElevation = 1.dp, 101 | tonalElevation = 1.dp, 102 | modifier = Modifier 103 | .fillMaxWidth() 104 | .nestedScroll( 105 | remember(state) { 106 | BottomSheetNestedScrollConnection( 107 | draggableState = state, 108 | orientation = Orientation.Vertical, 109 | onFling = { velocity -> 110 | scope.launch { state.settle(velocity) } 111 | } 112 | ) 113 | }, 114 | ) 115 | .anchoredDraggable( 116 | state = state, 117 | orientation = Orientation.Vertical 118 | ) 119 | .onSizeChanged { sheetSize -> 120 | val sheetHeight = sheetSize.height 121 | val newAnchors = DraggableAnchors { 122 | with(density) { 123 | // Bottom sheet height is 100 dp. 124 | SheetValue.Collapsed at (layoutHeight - 100.dp.toPx()) 125 | // Offset is 60% which means the bottom sheet takes 40% of the screen. 126 | SheetValue.PartiallyExpanded at (layoutHeight * 0.6f) 127 | // Bottom sheet height is equal to the height of its content. 128 | SheetValue.Expanded at maxOf(layoutHeight - sheetHeight, 0).toFloat() 129 | } 130 | } 131 | state.updateAnchors(newAnchors, state.targetValue) 132 | }, 133 | ) { 134 | sheetContent() 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /androidApp/src/main/kotlin/io/morfly/bottomsheet/sample/bottomsheet/CustomFinalizedDemoScreen.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) 2 | 3 | package io.morfly.bottomsheet.sample.bottomsheet 4 | 5 | import androidx.compose.foundation.ExperimentalFoundationApi 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.material3.ExperimentalMaterial3Api 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.derivedStateOf 10 | import androidx.compose.runtime.getValue 11 | import androidx.compose.runtime.mutableStateOf 12 | import androidx.compose.runtime.remember 13 | import androidx.compose.runtime.saveable.rememberSaveable 14 | import androidx.compose.runtime.setValue 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.unit.dp 17 | import io.morfly.bottomsheet.sample.bottomsheet.common.BottomSheetContent 18 | import io.morfly.bottomsheet.sample.bottomsheet.common.MapScreenContent 19 | import io.morfly.compose.bottomsheet.material3.BottomSheetScaffold 20 | import io.morfly.compose.bottomsheet.material3.layoutHeightDp 21 | import io.morfly.compose.bottomsheet.material3.rememberBottomSheetScaffoldState 22 | import io.morfly.compose.bottomsheet.material3.rememberBottomSheetState 23 | import io.morfly.compose.bottomsheet.material3.requireSheetVisibleHeightDp 24 | 25 | @Composable 26 | fun CustomFinalizedDemoScreen() { 27 | var isInitialState by rememberSaveable { mutableStateOf(true) } 28 | 29 | val sheetState = rememberBottomSheetState( 30 | initialValue = SheetValue.PartiallyExpanded, 31 | defineValues = { 32 | // Bottom sheet height is 100 dp. 33 | SheetValue.Collapsed at height(100.dp) 34 | if (isInitialState) { 35 | // Offset is 60% which means the bottom sheet takes 40% of the screen. 36 | SheetValue.PartiallyExpanded at offset(percent = 60) 37 | } 38 | // Bottom sheet height is equal to the height of its content. 39 | SheetValue.Expanded at contentHeight 40 | }, 41 | confirmValueChange = { 42 | if (isInitialState) { 43 | isInitialState = false 44 | refreshValues() 45 | } 46 | true 47 | } 48 | ) 49 | val scaffoldState = rememberBottomSheetScaffoldState(sheetState) 50 | 51 | BottomSheetScaffold( 52 | scaffoldState = scaffoldState, 53 | sheetContent = { 54 | BottomSheetContent() 55 | }, 56 | content = { 57 | val bottomPadding by remember { 58 | derivedStateOf { sheetState.requireSheetVisibleHeightDp() } 59 | } 60 | val isBottomSheetMoving by remember { 61 | derivedStateOf { sheetState.currentValue != sheetState.targetValue } 62 | } 63 | MapScreenContent( 64 | bottomPadding = bottomPadding, 65 | isBottomSheetMoving = isBottomSheetMoving, 66 | layoutHeight = sheetState.layoutHeightDp 67 | ) 68 | }, 69 | modifier = Modifier.fillMaxSize(), 70 | ) 71 | } -------------------------------------------------------------------------------- /androidApp/src/main/kotlin/io/morfly/bottomsheet/sample/bottomsheet/OfficialMaterial3DemoScreen.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalMaterial3Api::class) 2 | 3 | package io.morfly.bottomsheet.sample.bottomsheet 4 | 5 | import androidx.compose.foundation.layout.BoxWithConstraints 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.material3.BottomSheetScaffold 8 | import androidx.compose.material3.ExperimentalMaterial3Api 9 | import androidx.compose.material3.SheetValue 10 | import androidx.compose.material3.rememberBottomSheetScaffoldState 11 | import androidx.compose.material3.rememberStandardBottomSheetState 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.derivedStateOf 14 | import androidx.compose.runtime.getValue 15 | import androidx.compose.runtime.remember 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.platform.LocalDensity 18 | import androidx.compose.ui.unit.dp 19 | import io.morfly.bottomsheet.sample.bottomsheet.common.BottomSheetContent 20 | import io.morfly.bottomsheet.sample.bottomsheet.common.MapScreenContent 21 | import kotlin.math.roundToInt 22 | 23 | @Composable 24 | fun OfficialMaterial3DemoScreen() { 25 | val sheetState = rememberStandardBottomSheetState( 26 | initialValue = SheetValue.PartiallyExpanded 27 | ) 28 | val scaffoldState = rememberBottomSheetScaffoldState(sheetState) 29 | 30 | BoxWithConstraints { 31 | val layoutHeightPx = constraints.maxHeight 32 | 33 | val density = LocalDensity.current 34 | val bottomPadding by remember(layoutHeightPx) { 35 | derivedStateOf { 36 | val sheetVisibleHeightPx = layoutHeightPx - sheetState.requireOffset() 37 | with(density) { sheetVisibleHeightPx.roundToInt().toDp() } 38 | } 39 | } 40 | 41 | BottomSheetScaffold( 42 | sheetPeekHeight = 100.dp, 43 | scaffoldState = scaffoldState, 44 | sheetContent = { 45 | BottomSheetContent() 46 | }, 47 | content = { 48 | val isBottomSheetMoving by remember { 49 | derivedStateOf { sheetState.currentValue != sheetState.targetValue } 50 | } 51 | MapScreenContent( 52 | bottomPadding = bottomPadding, 53 | isBottomSheetMoving = isBottomSheetMoving, 54 | layoutHeight = maxHeight 55 | ) 56 | }, 57 | modifier = Modifier.fillMaxSize() 58 | ) 59 | } 60 | } -------------------------------------------------------------------------------- /androidApp/src/main/kotlin/io/morfly/bottomsheet/sample/bottomsheet/SheetValue.kt: -------------------------------------------------------------------------------- 1 | package io.morfly.bottomsheet.sample.bottomsheet 2 | 3 | enum class SheetValue { 4 | Collapsed, PartiallyExpanded, Expanded 5 | } -------------------------------------------------------------------------------- /androidApp/src/main/kotlin/io/morfly/bottomsheet/sample/bottomsheet/common/BottomSheetContent.kt: -------------------------------------------------------------------------------- 1 | package io.morfly.bottomsheet.sample.bottomsheet.common 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Spacer 5 | import androidx.compose.foundation.layout.height 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.lazy.LazyColumn 8 | import androidx.compose.foundation.shape.RoundedCornerShape 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.material3.Surface 11 | import androidx.compose.material3.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.remember 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.layout.ContentScale 16 | import androidx.compose.ui.unit.dp 17 | import coil.compose.AsyncImage 18 | import io.morfly.bottomsheet.sample.bottomsheet.common.BottomSheetContentSize.Large 19 | import io.morfly.bottomsheet.sample.bottomsheet.common.BottomSheetContentSize.Medium 20 | import io.morfly.bottomsheet.sample.bottomsheet.common.BottomSheetContentSize.Small 21 | 22 | enum class BottomSheetContentSize { Small, Medium, Large } 23 | 24 | @Composable 25 | fun BottomSheetContent( 26 | modifier: Modifier = Modifier, 27 | userScrollEnabled: Boolean = true, 28 | size: BottomSheetContentSize = Medium 29 | ) { 30 | val itemCount = remember(size) { 31 | when (size) { 32 | Small -> 0 33 | Medium -> 2 34 | Large -> pointsOfInterest.size 35 | } 36 | } 37 | LazyColumn( 38 | userScrollEnabled = userScrollEnabled, 39 | modifier = modifier 40 | .padding(horizontal = 16.dp) 41 | .padding(bottom = 16.dp) 42 | ) { 43 | item { 44 | Header(Modifier.padding(bottom = 16.dp)) 45 | } 46 | 47 | items(itemCount) { i -> 48 | PointOfInterestItem( 49 | pointOfInterest = pointsOfInterest[i], 50 | modifier = Modifier.padding(bottom = 16.dp) 51 | ) 52 | } 53 | } 54 | } 55 | 56 | @Composable 57 | private fun Header(modifier: Modifier = Modifier) { 58 | Column(modifier) { 59 | Text(text = "San Francisco, California", style = MaterialTheme.typography.headlineSmall) 60 | Spacer(Modifier.height(16.dp)) 61 | Text(text = "Iconic places", style = MaterialTheme.typography.titleMedium) 62 | } 63 | } 64 | 65 | @Composable 66 | private fun PointOfInterestItem( 67 | pointOfInterest: PointOfInterest, 68 | modifier: Modifier = Modifier 69 | ) { 70 | Surface( 71 | shape = RoundedCornerShape(16.dp), 72 | shadowElevation = 1.dp, 73 | modifier = modifier 74 | ) { 75 | Column { 76 | AsyncImage( 77 | model = pointOfInterest.photoUrl, 78 | contentDescription = null, 79 | contentScale = ContentScale.Crop, 80 | modifier = Modifier.height(190.dp) 81 | ) 82 | Column(Modifier.padding(16.dp)) { 83 | Text(text = pointOfInterest.name, style = MaterialTheme.typography.titleMedium) 84 | Text( 85 | text = pointOfInterest.license, 86 | style = MaterialTheme.typography.bodyMedium, 87 | color = MaterialTheme.colorScheme.onSurface 88 | ) 89 | } 90 | } 91 | } 92 | } 93 | 94 | private data class PointOfInterest(val name: String, val photoUrl: String, val license: String) 95 | 96 | private val pointsOfInterest = listOf( 97 | PointOfInterest( 98 | name = "Golden Gate", 99 | photoUrl = "https://images.unsplash.com/photo-1610312278520-bcc893a3ff1d?q=80&w=3494&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", 100 | // Photo by Varun Yadav on Unsplash. https://unsplash.com/photos/golden-gate-bridge-san-francisco-california-QhYTCG3CTeI 101 | license = "Photo by Varun Yadav on Unsplash" 102 | ), 103 | PointOfInterest( 104 | name = "The Painted Ladies", 105 | photoUrl = "https://images.unsplash.com/photo-1522735555435-a8fe18da2089?q=80&w=3540&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", 106 | // Photo by Aaron Kato on Unsplash. https://unsplash.com/photos/white-and-brown-2-storey-houses-with-vehicles-in-front-during-daytime-zcoDYal9GkQ 107 | license = "Photo by Aaron Kato on Unsplash" 108 | ), 109 | PointOfInterest( 110 | name = "Salesforce Tower", 111 | photoUrl = "https://images.unsplash.com/photo-1558623869-d6f8763a24f9?q=80&w=3522&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", 112 | // Photo by Denys Nevozhai on Unsplash. https://unsplash.com/photos/aerial-view-of-city-during-nighttime-wgtJfd2Jhnk 113 | license = "Photo by Denys Nevozhai on Unsplash" 114 | ), 115 | PointOfInterest( 116 | name = "Lombard Street", 117 | photoUrl = "https://plus.unsplash.com/premium_photo-1673483585905-439b19e0d30a?q=80&w=3348&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", 118 | // Photo by Casey Horner on Unsplash+. https://unsplash.com/photos/an-aerial-view-of-a-city-with-a-river-running-through-it-tQicpDWhIzk 119 | license = "Photo by Casey Horner on Unsplash+" 120 | ), 121 | ) -------------------------------------------------------------------------------- /androidApp/src/main/kotlin/io/morfly/bottomsheet/sample/bottomsheet/common/BottomSheetNestedScrollConnection.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalFoundationApi::class) 2 | 3 | package io.morfly.bottomsheet.sample.bottomsheet.common 4 | 5 | import androidx.compose.foundation.ExperimentalFoundationApi 6 | import androidx.compose.foundation.gestures.AnchoredDraggableState 7 | import androidx.compose.foundation.gestures.Orientation 8 | import androidx.compose.ui.geometry.Offset 9 | import androidx.compose.ui.input.nestedscroll.NestedScrollConnection 10 | import androidx.compose.ui.input.nestedscroll.NestedScrollSource 11 | import androidx.compose.ui.unit.Velocity 12 | 13 | /** 14 | * Slightly modified version of [androidx.compose.material3.ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection] 15 | */ 16 | @Suppress("FunctionName", "SameParameterValue") 17 | fun BottomSheetNestedScrollConnection( 18 | draggableState: AnchoredDraggableState, 19 | orientation: Orientation, 20 | onFling: (velocity: Float) -> Unit, 21 | ): NestedScrollConnection = object : NestedScrollConnection { 22 | override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { 23 | val delta = available.toFloat() 24 | return if (delta < 0 && source == NestedScrollSource.Drag) { 25 | draggableState.dispatchRawDelta(delta).toOffset() 26 | } else { 27 | Offset.Zero 28 | } 29 | } 30 | 31 | override fun onPostScroll( 32 | consumed: Offset, 33 | available: Offset, 34 | source: NestedScrollSource, 35 | ): Offset { 36 | return if (source == NestedScrollSource.Drag) { 37 | draggableState.dispatchRawDelta(available.toFloat()).toOffset() 38 | } else { 39 | Offset.Zero 40 | } 41 | } 42 | 43 | override suspend fun onPreFling(available: Velocity): Velocity { 44 | val toFling = available.toFloat() 45 | val currentOffset = draggableState.requireOffset() 46 | val minAnchor = draggableState.anchors.minAnchor() 47 | return if (toFling < 0 && currentOffset > minAnchor) { 48 | onFling(toFling) 49 | // since we go to the anchor with tween settling, consume all for the best UX 50 | available 51 | } else { 52 | Velocity.Zero 53 | } 54 | } 55 | 56 | override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { 57 | onFling(available.toFloat()) 58 | return available 59 | } 60 | 61 | private fun Float.toOffset(): Offset = Offset( 62 | x = if (orientation == Orientation.Horizontal) this else 0f, 63 | y = if (orientation == Orientation.Vertical) this else 0f, 64 | ) 65 | 66 | @JvmName("velocityToFloat") 67 | private fun Velocity.toFloat() = if (orientation == Orientation.Horizontal) x else y 68 | 69 | @JvmName("offsetToFloat") 70 | private fun Offset.toFloat(): Float = if (orientation == Orientation.Horizontal) x else y 71 | } -------------------------------------------------------------------------------- /androidApp/src/main/kotlin/io/morfly/bottomsheet/sample/bottomsheet/common/MapScreenContent.kt: -------------------------------------------------------------------------------- 1 | package io.morfly.bottomsheet.sample.bottomsheet.common 2 | 3 | import android.content.Context 4 | import android.content.res.Configuration 5 | import androidx.compose.foundation.isSystemInDarkTheme 6 | import androidx.compose.foundation.layout.PaddingValues 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.LaunchedEffect 10 | import androidx.compose.runtime.getValue 11 | import androidx.compose.runtime.mutableStateOf 12 | import androidx.compose.runtime.remember 13 | import androidx.compose.runtime.setValue 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.platform.LocalConfiguration 16 | import androidx.compose.ui.platform.LocalContext 17 | import androidx.compose.ui.platform.LocalDensity 18 | import androidx.compose.ui.unit.Dp 19 | import androidx.compose.ui.unit.dp 20 | import com.google.android.gms.maps.CameraUpdateFactory 21 | import com.google.android.gms.maps.model.CameraPosition 22 | import com.google.android.gms.maps.model.LatLng 23 | import com.google.android.gms.maps.model.MapStyleOptions 24 | import com.google.maps.android.compose.CameraMoveStartedReason 25 | import com.google.maps.android.compose.CameraPositionState 26 | import com.google.maps.android.compose.GoogleMap 27 | import com.google.maps.android.compose.MapProperties 28 | import com.google.maps.android.compose.MapUiSettings 29 | import com.google.maps.android.compose.rememberCameraPositionState 30 | import io.morfly.bottomsheet.sample.R 31 | 32 | private const val DefaultMapZoom = 13f 33 | private val SanFranciscoLocation = LatLng(37.77446, -122.42064) 34 | private val MapUiOffsetLimit = 100.dp 35 | 36 | @Composable 37 | fun MapScreenContent( 38 | modifier: Modifier = Modifier, 39 | bottomPadding: Dp = 0.dp, 40 | isBottomSheetMoving: Boolean = false, 41 | layoutHeight: Dp = Dp.Unspecified, 42 | ) { 43 | val cameraPositionState = rememberCameraPositionState { 44 | position = CameraPosition.fromLatLngZoom(SanFranciscoLocation, DefaultMapZoom) 45 | } 46 | 47 | val maxBottomPadding = remember(layoutHeight) { layoutHeight - MapUiOffsetLimit } 48 | val mapPadding = rememberMapPadding(bottomPadding, maxBottomPadding) 49 | 50 | AdjustedCameraPositionEffect( 51 | camera = cameraPositionState, 52 | isBottomSheetMoving = isBottomSheetMoving, 53 | bottomPadding = mapPadding.calculateBottomPadding(), 54 | ) 55 | 56 | GoogleMap( 57 | modifier = modifier.fillMaxSize(), 58 | cameraPositionState = cameraPositionState, 59 | uiSettings = rememberMapUiSettings(), 60 | properties = rememberMapProperties(), 61 | contentPadding = mapPadding 62 | ) 63 | } 64 | 65 | @Composable 66 | private fun AdjustedCameraPositionEffect( 67 | camera: CameraPositionState, 68 | isBottomSheetMoving: Boolean, 69 | bottomPadding: Dp, 70 | ) { 71 | var cameraLocation by remember { mutableStateOf(camera.position.target) } 72 | LaunchedEffect(camera.isMoving, camera.cameraMoveStartedReason) { 73 | if (!camera.isMoving && camera.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) { 74 | cameraLocation = camera.position.target 75 | } 76 | } 77 | 78 | var isCameraInitialized by remember { mutableStateOf(false) } 79 | val density = LocalDensity.current 80 | LaunchedEffect(isBottomSheetMoving) { 81 | if (isBottomSheetMoving) return@LaunchedEffect 82 | 83 | // The map does not respect the initial bottom padding value. The CameraPositionState in 84 | // this case returns the camera location as if the padding was not set. Therefore, the 85 | // camera must be manually shifted according to the initial padding value. 86 | if (!isCameraInitialized) { 87 | isCameraInitialized = true 88 | val verticalShiftPx = with(density) { bottomPadding.toPx() / 2 } 89 | val update = CameraUpdateFactory.scrollBy(0f, verticalShiftPx) 90 | camera.animate(update) 91 | } else { 92 | val update = CameraUpdateFactory.newLatLng(cameraLocation) 93 | camera.animate(update) 94 | } 95 | } 96 | } 97 | 98 | @Composable 99 | private fun rememberMapPadding(bottomPadding: Dp, maxBottomPadding: Dp): PaddingValues { 100 | val configuration = LocalConfiguration.current 101 | val isPortrait = configuration.orientation == Configuration.ORIENTATION_PORTRAIT 102 | return if (isPortrait) { 103 | rememberPortraitMapPadding(bottomPadding, maxBottomPadding) 104 | } else { 105 | remember { PaddingValues() } 106 | } 107 | } 108 | 109 | @Composable 110 | private fun rememberPortraitMapPadding(bottomPadding: Dp, maxBottomPadding: Dp): PaddingValues { 111 | return remember(bottomPadding, maxBottomPadding) { 112 | PaddingValues( 113 | start = 16.dp, 114 | end = 16.dp, 115 | bottom = bottomPadding.takeIf { it < maxBottomPadding } ?: maxBottomPadding 116 | ) 117 | } 118 | } 119 | 120 | @Composable 121 | private fun rememberMapUiSettings(): MapUiSettings { 122 | return remember { 123 | MapUiSettings( 124 | compassEnabled = true, 125 | indoorLevelPickerEnabled = true, 126 | mapToolbarEnabled = false, 127 | myLocationButtonEnabled = false, 128 | rotationGesturesEnabled = false, 129 | scrollGesturesEnabled = true, 130 | scrollGesturesEnabledDuringRotateOrZoom = false, 131 | tiltGesturesEnabled = false, 132 | zoomControlsEnabled = false, 133 | zoomGesturesEnabled = true, 134 | ) 135 | } 136 | } 137 | 138 | @Composable 139 | private fun rememberMapProperties(): MapProperties { 140 | val context = LocalContext.current 141 | val isDarkTheme = isSystemInDarkTheme() 142 | return remember(isDarkTheme) { 143 | val mapStyleOptions = if (isDarkTheme) { 144 | mapStyleDarkOptions(context) 145 | } else { 146 | mapStyleOptions(context) 147 | } 148 | MapProperties(mapStyleOptions = mapStyleOptions) 149 | } 150 | } 151 | 152 | fun mapStyleOptions(context: Context): MapStyleOptions { 153 | return MapStyleOptions.loadRawResourceStyle(context, R.raw.map_style) 154 | } 155 | 156 | fun mapStyleDarkOptions(context: Context): MapStyleOptions { 157 | return MapStyleOptions.loadRawResourceStyle(context, R.raw.map_style_dark) 158 | } 159 | -------------------------------------------------------------------------------- /androidApp/src/main/kotlin/io/morfly/bottomsheet/sample/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package io.morfly.bottomsheet.sample.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple80 = Color(0xFFD0BCFF) 6 | val PurpleGrey80 = Color(0xFFCCC2DC) 7 | val Pink80 = Color(0xFFEFB8C8) 8 | 9 | val Purple40 = Color(0xFF6650a4) 10 | val PurpleGrey40 = Color(0xFF625b71) 11 | val Pink40 = Color(0xFF7D5260) -------------------------------------------------------------------------------- /androidApp/src/main/kotlin/io/morfly/bottomsheet/sample/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package io.morfly.bottomsheet.sample.theme 2 | 3 | import android.app.Activity 4 | import android.os.Build 5 | import androidx.compose.foundation.isSystemInDarkTheme 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.darkColorScheme 8 | import androidx.compose.material3.dynamicDarkColorScheme 9 | import androidx.compose.material3.dynamicLightColorScheme 10 | import androidx.compose.material3.lightColorScheme 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.SideEffect 13 | import androidx.compose.ui.graphics.toArgb 14 | import androidx.compose.ui.platform.LocalContext 15 | import androidx.compose.ui.platform.LocalView 16 | import androidx.core.view.WindowCompat 17 | 18 | private val DarkColorScheme = darkColorScheme( 19 | primary = Purple80, 20 | secondary = PurpleGrey80, 21 | tertiary = Pink80 22 | ) 23 | 24 | private val LightColorScheme = lightColorScheme( 25 | primary = Purple40, 26 | secondary = PurpleGrey40, 27 | tertiary = Pink40 28 | 29 | /* Other default colors to override 30 | background = Color(0xFFFFFBFE), 31 | surface = Color(0xFFFFFBFE), 32 | onPrimary = Color.White, 33 | onSecondary = Color.White, 34 | onTertiary = Color.White, 35 | onBackground = Color(0xFF1C1B1F), 36 | onSurface = Color(0xFF1C1B1F), 37 | */ 38 | ) 39 | 40 | @Composable 41 | fun MultiStateBottomSheetSampleTheme( 42 | darkTheme: Boolean = isSystemInDarkTheme(), 43 | // Dynamic color is available on Android 12+ 44 | dynamicColor: Boolean = true, 45 | content: @Composable () -> Unit 46 | ) { 47 | val colorScheme = when { 48 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 49 | val context = LocalContext.current 50 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 51 | } 52 | 53 | darkTheme -> DarkColorScheme 54 | else -> LightColorScheme 55 | } 56 | // This code is commented to enable edge-to-edge. 57 | // val view = LocalView.current 58 | // if (!view.isInEditMode) { 59 | // SideEffect { 60 | // val window = (view.context as Activity).window 61 | // window.statusBarColor = colorScheme.primary.toArgb() 62 | // WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme 63 | // } 64 | // } 65 | 66 | MaterialTheme( 67 | colorScheme = colorScheme, 68 | typography = Typography, 69 | content = content 70 | ) 71 | } -------------------------------------------------------------------------------- /androidApp/src/main/kotlin/io/morfly/bottomsheet/sample/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package io.morfly.bottomsheet.sample.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | bodyLarge = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp, 15 | lineHeight = 24.sp, 16 | letterSpacing = 0.5.sp 17 | ), 18 | titleLarge = TextStyle( 19 | fontFamily = FontFamily.Default, 20 | fontWeight = FontWeight.Normal, 21 | fontSize = 22.sp, 22 | lineHeight = 28.sp, 23 | letterSpacing = 0.sp 24 | ), 25 | labelSmall = TextStyle( 26 | fontFamily = FontFamily.Default, 27 | fontWeight = FontWeight.Medium, 28 | fontSize = 11.sp, 29 | lineHeight = 16.sp, 30 | letterSpacing = 0.5.sp 31 | ) 32 | ) -------------------------------------------------------------------------------- /androidApp/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /androidApp/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Morfly/advanced-bottomsheet-compose/b8dea9ae58bab847dc1ea83fd07077e801ec32d0/androidApp/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Morfly/advanced-bottomsheet-compose/b8dea9ae58bab847dc1ea83fd07077e801ec32d0/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Morfly/advanced-bottomsheet-compose/b8dea9ae58bab847dc1ea83fd07077e801ec32d0/androidApp/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Morfly/advanced-bottomsheet-compose/b8dea9ae58bab847dc1ea83fd07077e801ec32d0/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Morfly/advanced-bottomsheet-compose/b8dea9ae58bab847dc1ea83fd07077e801ec32d0/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Morfly/advanced-bottomsheet-compose/b8dea9ae58bab847dc1ea83fd07077e801ec32d0/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Morfly/advanced-bottomsheet-compose/b8dea9ae58bab847dc1ea83fd07077e801ec32d0/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Morfly/advanced-bottomsheet-compose/b8dea9ae58bab847dc1ea83fd07077e801ec32d0/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Morfly/advanced-bottomsheet-compose/b8dea9ae58bab847dc1ea83fd07077e801ec32d0/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Morfly/advanced-bottomsheet-compose/b8dea9ae58bab847dc1ea83fd07077e801ec32d0/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /androidApp/src/main/res/raw/map_style.json: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /androidApp/src/main/res/raw/map_style_dark.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "featureType": "all", 4 | "elementType": "geometry", 5 | "stylers": [ 6 | { 7 | "color": "#242f3e" 8 | } 9 | ] 10 | }, 11 | { 12 | "featureType": "all", 13 | "elementType": "labels.text.stroke", 14 | "stylers": [ 15 | { 16 | "lightness": -80 17 | } 18 | ] 19 | }, 20 | { 21 | "featureType": "administrative", 22 | "elementType": "labels.text.fill", 23 | "stylers": [ 24 | { 25 | "color": "#746855" 26 | } 27 | ] 28 | }, 29 | { 30 | "featureType": "administrative.locality", 31 | "elementType": "labels.text.fill", 32 | "stylers": [ 33 | { 34 | "color": "#d59563" 35 | } 36 | ] 37 | }, 38 | { 39 | "featureType": "poi", 40 | "elementType": "labels.text.fill", 41 | "stylers": [ 42 | { 43 | "color": "#d59563" 44 | } 45 | ] 46 | }, 47 | { 48 | "featureType": "poi.park", 49 | "elementType": "geometry", 50 | "stylers": [ 51 | { 52 | "color": "#263c3f" 53 | } 54 | ] 55 | }, 56 | { 57 | "featureType": "poi.park", 58 | "elementType": "labels.text.fill", 59 | "stylers": [ 60 | { 61 | "color": "#6b9a76" 62 | } 63 | ] 64 | }, 65 | { 66 | "featureType": "road", 67 | "elementType": "geometry.fill", 68 | "stylers": [ 69 | { 70 | "color": "#2b3544" 71 | } 72 | ] 73 | }, 74 | { 75 | "featureType": "road", 76 | "elementType": "labels.text.fill", 77 | "stylers": [ 78 | { 79 | "color": "#9ca5b3" 80 | } 81 | ] 82 | }, 83 | { 84 | "featureType": "road.arterial", 85 | "elementType": "geometry.fill", 86 | "stylers": [ 87 | { 88 | "color": "#38414e" 89 | } 90 | ] 91 | }, 92 | { 93 | "featureType": "road.arterial", 94 | "elementType": "geometry.stroke", 95 | "stylers": [ 96 | { 97 | "color": "#212a37" 98 | } 99 | ] 100 | }, 101 | { 102 | "featureType": "road.highway", 103 | "elementType": "geometry.fill", 104 | "stylers": [ 105 | { 106 | "color": "#746855" 107 | } 108 | ] 109 | }, 110 | { 111 | "featureType": "road.highway", 112 | "elementType": "geometry.stroke", 113 | "stylers": [ 114 | { 115 | "color": "#1f2835" 116 | } 117 | ] 118 | }, 119 | { 120 | "featureType": "road.highway", 121 | "elementType": "labels.text.fill", 122 | "stylers": [ 123 | { 124 | "color": "#f3d19c" 125 | } 126 | ] 127 | }, 128 | { 129 | "featureType": "road.local", 130 | "elementType": "geometry.fill", 131 | "stylers": [ 132 | { 133 | "color": "#38414e" 134 | } 135 | ] 136 | }, 137 | { 138 | "featureType": "road.local", 139 | "elementType": "geometry.stroke", 140 | "stylers": [ 141 | { 142 | "color": "#212a37" 143 | } 144 | ] 145 | }, 146 | { 147 | "featureType": "transit", 148 | "elementType": "geometry", 149 | "stylers": [ 150 | { 151 | "color": "#2f3948" 152 | } 153 | ] 154 | }, 155 | { 156 | "featureType": "transit.station", 157 | "elementType": "labels.text.fill", 158 | "stylers": [ 159 | { 160 | "color": "#d59563" 161 | } 162 | ] 163 | }, 164 | { 165 | "featureType": "water", 166 | "elementType": "geometry", 167 | "stylers": [ 168 | { 169 | "color": "#17263c" 170 | } 171 | ] 172 | }, 173 | { 174 | "featureType": "water", 175 | "elementType": "labels.text.fill", 176 | "stylers": [ 177 | { 178 | "color": "#515c6d" 179 | } 180 | ] 181 | }, 182 | { 183 | "featureType": "water", 184 | "elementType": "labels.text.stroke", 185 | "stylers": [ 186 | { 187 | "lightness": -20 188 | } 189 | ] 190 | } 191 | ] -------------------------------------------------------------------------------- /androidApp/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /androidApp/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Multi State Bottom Sheet Sample 3 | -------------------------------------------------------------------------------- /androidApp/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |