├── .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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | xmlns:android
18 |
19 | ^$
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | xmlns:.*
29 |
30 | ^$
31 |
32 |
33 | BY_NAME
34 |
35 |
36 |
37 |
38 |
39 |
40 | .*:id
41 |
42 | http://schemas.android.com/apk/res/android
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | .*:name
52 |
53 | http://schemas.android.com/apk/res/android
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | name
63 |
64 | ^$
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 | style
74 |
75 | ^$
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 | .*
85 |
86 | ^$
87 |
88 |
89 | BY_NAME
90 |
91 |
92 |
93 |
94 |
95 |
96 | .*
97 |
98 | http://schemas.android.com/apk/res/android
99 |
100 |
101 | ANDROID_ATTRIBUTE_ORDER
102 |
103 |
104 |
105 |
106 |
107 |
108 | .*
109 |
110 | .*
111 |
112 |
113 | BY_NAME
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
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 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/.idea/kotlinc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/migrations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
Advanced Bottom Sheet for Compose
3 |
4 |
5 |
6 |
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 | 
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 | [](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 |
5 |
--------------------------------------------------------------------------------
/androidApp/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/androidApp/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/build-tools/conventions/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | `kotlin-dsl`
3 | }
4 |
5 | dependencies {
6 | // Makes 'libs' version catalog visible and type-safe for precompiled plugins.
7 | // https://github.com/gradle/gradle/issues/15383#issuecomment-1245546796
8 | implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location))
9 |
10 | compileOnly(libs.gradlePlugin.mavenPublish)
11 | }
12 |
13 | gradlePlugin {
14 | plugins {
15 | val mavenPublish by registering {
16 | id = "mavenPublish"
17 | implementationClass = "MavenPublishPlugin"
18 | }
19 | }
20 | }
--------------------------------------------------------------------------------
/build-tools/conventions/src/main/kotlin/MavenPublishPlugin.kt:
--------------------------------------------------------------------------------
1 | import io.morfly.buildtools.ConventionPlugin
2 | import io.morfly.buildtools.libs
3 | import io.morfly.buildtools.mavenPublishing
4 | import com.vanniktech.maven.publish.SonatypeHost
5 |
6 | class MavenPublishPlugin : ConventionPlugin({
7 | with(pluginManager) {
8 | apply(libs.plugins.vanniktech.maven.publish.get().pluginId)
9 | }
10 |
11 | mavenPublishing {
12 | val version: String by properties
13 | coordinates(
14 | groupId = "io.morfly.compose",
15 | artifactId = project.name,
16 | version = version
17 | )
18 |
19 | pom {
20 | name.set("Advanced Bottom Sheet for Compose")
21 | description.set("Advanced bottom sheet component for Compose with flexible configuration")
22 | inceptionYear.set("2024")
23 | url.set("https://github.com/Morfly/advanced-bottomsheet-compose")
24 | licenses {
25 | license {
26 | name.set("The Apache License, Version 2.0")
27 | url.set("https://www.apache.org/licenses/LICENSE-2.0.txt")
28 | distribution.set("https://www.apache.org/licenses/LICENSE-2.0.txt")
29 | }
30 | }
31 | developers {
32 | developer {
33 | id.set("Morfly")
34 | name.set("Pavlo Stavytskyi")
35 | url.set("https://github.com/Morfly")
36 | }
37 | }
38 | scm {
39 | url.set("https://github.com/Morfly/advanced-bottomsheet-compose")
40 | connection.set("scm:git:git://github.com/Morfly/advanced-bottomsheet-compose.git")
41 | developerConnection.set("scm:git:ssh://git@github.com/Morfly/advanced-bottomsheet-compose.git")
42 | }
43 | }
44 |
45 | publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL)
46 | signAllPublications()
47 | }
48 | })
49 |
--------------------------------------------------------------------------------
/build-tools/conventions/src/main/kotlin/io/morfly/buildtools/ConventionPlugin.kt:
--------------------------------------------------------------------------------
1 | package io.morfly.buildtools
2 |
3 | import org.gradle.api.Plugin
4 | import org.gradle.api.Project
5 |
6 | abstract class ConventionPlugin(
7 | private val body: Project.() -> Unit
8 | ) : Plugin {
9 |
10 | override fun apply(target: Project) = target.body()
11 | }
--------------------------------------------------------------------------------
/build-tools/conventions/src/main/kotlin/io/morfly/buildtools/GradleDslExtensions.kt:
--------------------------------------------------------------------------------
1 | package io.morfly.buildtools
2 |
3 | import com.vanniktech.maven.publish.MavenPublishBaseExtension
4 | import org.gradle.accessors.dm.LibrariesForLibs
5 | import org.gradle.api.Project
6 | import org.gradle.kotlin.dsl.getByType
7 |
8 | // Makes 'libs' version catalog available for precompiled plugins in a type-safe manner.
9 | // https://github.com/gradle/gradle/issues/15383#issuecomment-1245546796
10 | val Project.libs get() = extensions.getByType()
11 |
12 | fun Project.mavenPublishing(configure: MavenPublishBaseExtension.() -> Unit) {
13 | extensions.configure("mavenPublishing", configure)
14 | }
--------------------------------------------------------------------------------
/build-tools/gradle.properties:
--------------------------------------------------------------------------------
1 | kotlin.code.style=official
2 | kotlin.caching.enabled=true
3 | kotlin.incremental=true
4 | kotlin.parallel.tasks.in.project=true
5 |
6 | org.gradle.caching=true
7 | org.gradle.parallel=true
8 | org.gradle.configureondemand=true
9 | org.gradle.vfs.watch=true
10 | org.gradle.unsafe.configuration-cache=true
--------------------------------------------------------------------------------
/build-tools/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | dependencyResolutionManagement {
2 | repositories {
3 | google()
4 | mavenCentral()
5 | gradlePluginPortal()
6 | }
7 | versionCatalogs {
8 | create("libs") {
9 | from(files("../gradle/libs.versions.toml"))
10 | }
11 | }
12 | }
13 |
14 | rootProject.name = "build-tools"
15 | include(":conventions")
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.androidApplication) apply false
3 | alias(libs.plugins.androidLibrary) apply false
4 | alias(libs.plugins.kotlinAndroid) apply false
5 | alias(libs.plugins.kotlinMultiplatform) apply false
6 | alias(libs.plugins.jetbrainsCompose) apply false
7 | alias(libs.plugins.androidMapsSecrets) apply false
8 | alias(libs.plugins.vanniktech.maven.publish) apply false
9 | alias(libs.plugins.dokka) apply false
10 | }
--------------------------------------------------------------------------------
/demos/demo_cover.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Morfly/advanced-bottomsheet-compose/b8dea9ae58bab847dc1ea83fd07077e801ec32d0/demos/demo_cover.png
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
2 |
3 | android.useAndroidX=true
4 | android.nonTransitiveRClass=true
5 |
6 | kotlin.code.style=official
7 | kotlin.caching.enabled=true
8 | kotlin.incremental=true
9 | kotlin.parallel.tasks.in.project=true
10 |
11 | org.gradle.caching=true
12 | org.gradle.parallel=true
13 | org.gradle.configureondemand=true
14 | org.gradle.vfs.watch=true
15 | org.gradle.unsafe.configuration-cache=true
16 |
17 | version = 0.1.0
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | agp = "8.3.2"
3 | kotlin = "1.9.20"
4 | coreKtx = "1.12.0"
5 | lifecycleRuntimeKtx = "2.7.0"
6 | activityCompose = "1.8.2"
7 | navigationCompose = "2.7.7"
8 | mapsCompose = "4.4.1"
9 | mapsSecrets = "2.0.1"
10 | composeBom = "2024.04.01"
11 | composeCompiler = "1.5.5"
12 | jetbrainsCompose = "1.6.2"
13 | appcompat = "1.6.1"
14 | coil = "2.6.0"
15 | mavenPublish = "0.28.0"
16 | dokka = "1.9.0"
17 | conventionPlugin = "ignored"
18 |
19 | [libraries]
20 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
21 | androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
22 | androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
23 | androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
24 | android-maps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "mapsCompose" }
25 | compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
26 | compose-ui = { group = "androidx.compose.ui", name = "ui" }
27 | compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
28 | compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
29 | compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
30 | compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
31 | compose-material3 = { group = "androidx.compose.material3", name = "material3" }
32 | compose-navigation = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
33 | compose-coil = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
34 | # ===== Gradle plugins =====
35 | gradlePlugin-mavenPublish = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "mavenPublish" }
36 |
37 | # ===== Temporary artifacts with versions to overcome bug during publishing to Maven Central =====
38 | compose-material3-ver = { group = "androidx.compose.material3", name = "material3", version = "1.2.1" }
39 | compose-ui-ver = { group = "androidx.compose.ui", name = "ui", version = "1.6.6" }
40 | compose-ui-graphics-ver = { group = "androidx.compose.ui", name = "ui-graphics", version = "1.6.6" }
41 |
42 | [plugins]
43 | androidApplication = { id = "com.android.application", version.ref = "agp" }
44 | androidLibrary = { id = "com.android.library", version.ref = "agp" }
45 | kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
46 | kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
47 | jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "jetbrainsCompose" }
48 | androidMapsSecrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "mapsSecrets" }
49 | vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" }
50 | dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" }
51 | # ===== Convention Plugins =====
52 | mavenPublish = { id = "mavenPublish", version.ref = "conventionPlugin" }
53 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Morfly/advanced-bottomsheet-compose/b8dea9ae58bab847dc1ea83fd07077e801ec32d0/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
4 | networkTimeout=10000
5 | validateDistributionUrl=true
6 | zipStoreBase=GRADLE_USER_HOME
7 | zipStorePath=wrapper/dists
8 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | #
4 | # Copyright © 2015-2021 the original authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | #
21 | # Gradle start up script for POSIX generated by Gradle.
22 | #
23 | # Important for running:
24 | #
25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
26 | # noncompliant, but you have some other compliant shell such as ksh or
27 | # bash, then to run this script, type that shell name before the whole
28 | # command line, like:
29 | #
30 | # ksh Gradle
31 | #
32 | # Busybox and similar reduced shells will NOT work, because this script
33 | # requires all of these POSIX shell features:
34 | # * functions;
35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»;
37 | # * compound commands having a testable exit status, especially «case»;
38 | # * various built-in commands including «command», «set», and «ulimit».
39 | #
40 | # Important for patching:
41 | #
42 | # (2) This script targets any POSIX shell, so it avoids extensions provided
43 | # by Bash, Ksh, etc; in particular arrays are avoided.
44 | #
45 | # The "traditional" practice of packing multiple parameters into a
46 | # space-separated string is a well documented source of bugs and security
47 | # problems, so this is (mostly) avoided, by progressively accumulating
48 | # options in "$@", and eventually passing that to Java.
49 | #
50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
52 | # see the in-line comments for details.
53 | #
54 | # There are tweaks for specific operating systems such as AIX, CygWin,
55 | # Darwin, MinGW, and NonStop.
56 | #
57 | # (3) This script is generated from the Groovy template
58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
59 | # within the Gradle project.
60 | #
61 | # You can find Gradle at https://github.com/gradle/gradle/.
62 | #
63 | ##############################################################################
64 |
65 | # Attempt to set APP_HOME
66 |
67 | # Resolve links: $0 may be a link
68 | app_path=$0
69 |
70 | # Need this for daisy-chained symlinks.
71 | while
72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
73 | [ -h "$app_path" ]
74 | do
75 | ls=$( ls -ld "$app_path" )
76 | link=${ls#*' -> '}
77 | case $link in #(
78 | /*) app_path=$link ;; #(
79 | *) app_path=$APP_HOME$link ;;
80 | esac
81 | done
82 |
83 | # This is normally unused
84 | # shellcheck disable=SC2034
85 | APP_BASE_NAME=${0##*/}
86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
88 |
89 | # Use the maximum available, or set MAX_FD != -1 to use that value.
90 | MAX_FD=maximum
91 |
92 | warn () {
93 | echo "$*"
94 | } >&2
95 |
96 | die () {
97 | echo
98 | echo "$*"
99 | echo
100 | exit 1
101 | } >&2
102 |
103 | # OS specific support (must be 'true' or 'false').
104 | cygwin=false
105 | msys=false
106 | darwin=false
107 | nonstop=false
108 | case "$( uname )" in #(
109 | CYGWIN* ) cygwin=true ;; #(
110 | Darwin* ) darwin=true ;; #(
111 | MSYS* | MINGW* ) msys=true ;; #(
112 | NONSTOP* ) nonstop=true ;;
113 | esac
114 |
115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
116 |
117 |
118 | # Determine the Java command to use to start the JVM.
119 | if [ -n "$JAVA_HOME" ] ; then
120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
121 | # IBM's JDK on AIX uses strange locations for the executables
122 | JAVACMD=$JAVA_HOME/jre/sh/java
123 | else
124 | JAVACMD=$JAVA_HOME/bin/java
125 | fi
126 | if [ ! -x "$JAVACMD" ] ; then
127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
128 |
129 | Please set the JAVA_HOME variable in your environment to match the
130 | location of your Java installation."
131 | fi
132 | else
133 | JAVACMD=java
134 | if ! command -v java >/dev/null 2>&1
135 | then
136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
137 |
138 | Please set the JAVA_HOME variable in your environment to match the
139 | location of your Java installation."
140 | fi
141 | fi
142 |
143 | # Increase the maximum file descriptors if we can.
144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
145 | case $MAX_FD in #(
146 | max*)
147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
148 | # shellcheck disable=SC2039,SC3045
149 | MAX_FD=$( ulimit -H -n ) ||
150 | warn "Could not query maximum file descriptor limit"
151 | esac
152 | case $MAX_FD in #(
153 | '' | soft) :;; #(
154 | *)
155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
156 | # shellcheck disable=SC2039,SC3045
157 | ulimit -n "$MAX_FD" ||
158 | warn "Could not set maximum file descriptor limit to $MAX_FD"
159 | esac
160 | fi
161 |
162 | # Collect all arguments for the java command, stacking in reverse order:
163 | # * args from the command line
164 | # * the main class name
165 | # * -classpath
166 | # * -D...appname settings
167 | # * --module-path (only if needed)
168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
169 |
170 | # For Cygwin or MSYS, switch paths to Windows format before running java
171 | if "$cygwin" || "$msys" ; then
172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
174 |
175 | JAVACMD=$( cygpath --unix "$JAVACMD" )
176 |
177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
178 | for arg do
179 | if
180 | case $arg in #(
181 | -*) false ;; # don't mess with options #(
182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
183 | [ -e "$t" ] ;; #(
184 | *) false ;;
185 | esac
186 | then
187 | arg=$( cygpath --path --ignore --mixed "$arg" )
188 | fi
189 | # Roll the args list around exactly as many times as the number of
190 | # args, so each arg winds up back in the position where it started, but
191 | # possibly modified.
192 | #
193 | # NB: a `for` loop captures its iteration list before it begins, so
194 | # changing the positional parameters here affects neither the number of
195 | # iterations, nor the values presented in `arg`.
196 | shift # remove old arg
197 | set -- "$@" "$arg" # push replacement arg
198 | done
199 | fi
200 |
201 |
202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
204 |
205 | # Collect all arguments for the java command:
206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
207 | # and any embedded shellness will be escaped.
208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
209 | # treated as '${Hostname}' itself on the command line.
210 |
211 | set -- \
212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \
213 | -classpath "$CLASSPATH" \
214 | org.gradle.wrapper.GradleWrapperMain \
215 | "$@"
216 |
217 | # Stop when "xargs" is not available.
218 | if ! command -v xargs >/dev/null 2>&1
219 | then
220 | die "xargs is not available"
221 | fi
222 |
223 | # Use "xargs" to parse quoted args.
224 | #
225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed.
226 | #
227 | # In Bash we could simply go:
228 | #
229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) &&
230 | # set -- "${ARGS[@]}" "$@"
231 | #
232 | # but POSIX shell has neither arrays nor command substitution, so instead we
233 | # post-process each arg (as a line of input to sed) to backslash-escape any
234 | # character that might be a shell metacharacter, then use eval to reverse
235 | # that process (while maintaining the separation between arguments), and wrap
236 | # the whole thing up as a single "set" statement.
237 | #
238 | # This will of course break if any of these variables contains a newline or
239 | # an unmatched quote.
240 | #
241 |
242 | eval "set -- $(
243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
244 | xargs -n1 |
245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
246 | tr '\n' ' '
247 | )" '"$@"'
248 |
249 | exec "$JAVACMD" "$@"
250 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%"=="" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%"=="" set DIRNAME=.
29 | @rem This is normally unused
30 | set APP_BASE_NAME=%~n0
31 | set APP_HOME=%DIRNAME%
32 |
33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
35 |
36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
38 |
39 | @rem Find java.exe
40 | if defined JAVA_HOME goto findJavaFromJavaHome
41 |
42 | set JAVA_EXE=java.exe
43 | %JAVA_EXE% -version >NUL 2>&1
44 | if %ERRORLEVEL% equ 0 goto execute
45 |
46 | echo. 1>&2
47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
48 | echo. 1>&2
49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
50 | echo location of your Java installation. 1>&2
51 |
52 | goto fail
53 |
54 | :findJavaFromJavaHome
55 | set JAVA_HOME=%JAVA_HOME:"=%
56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
57 |
58 | if exist "%JAVA_EXE%" goto execute
59 |
60 | echo. 1>&2
61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
62 | echo. 1>&2
63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
64 | echo location of your Java installation. 1>&2
65 |
66 | goto fail
67 |
68 | :execute
69 | @rem Setup the command line
70 |
71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
72 |
73 |
74 | @rem Execute Gradle
75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
76 |
77 | :end
78 | @rem End local scope for the variables with windows NT shell
79 | if %ERRORLEVEL% equ 0 goto mainEnd
80 |
81 | :fail
82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
83 | rem the _cmd.exe /c_ return code!
84 | set EXIT_CODE=%ERRORLEVEL%
85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
87 | exit /b %EXIT_CODE%
88 |
89 | :mainEnd
90 | if "%OS%"=="Windows_NT" endlocal
91 |
92 | :omega
93 |
--------------------------------------------------------------------------------
/local.defaults.properties:
--------------------------------------------------------------------------------
1 | MAPS_API_KEY=DEFAULT_API_KEY
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google {
4 | content {
5 | includeGroupByRegex("com\\.android.*")
6 | includeGroupByRegex("com\\.google.*")
7 | includeGroupByRegex("androidx.*")
8 | }
9 | }
10 | mavenCentral()
11 | gradlePluginPortal()
12 | }
13 | includeBuild("build-tools")
14 | }
15 | dependencyResolutionManagement {
16 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
17 | repositories {
18 | google()
19 | mavenCentral()
20 | }
21 | }
22 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
23 |
24 | rootProject.name = "advanced-bottomsheet-compose"
25 | include(":androidApp")
26 | include(":advanced-bottomsheet-material3")
27 |
--------------------------------------------------------------------------------