├── .github
├── dependabot.yaml
└── workflows
│ └── android.yaml
├── .gitignore
├── LICENSE
├── README.md
├── app
├── .gitignore
├── build.gradle
├── proguard-rules.pro
└── src
│ └── main
│ ├── AndroidManifest.xml
│ ├── java
│ └── com
│ │ └── dokar
│ │ └── draggablemenu
│ │ └── sample
│ │ ├── MainActivity.kt
│ │ ├── SampleMenuItem.kt
│ │ ├── SampleOptions.kt
│ │ ├── SlidingUpLayout.kt
│ │ └── theme
│ │ ├── Color.kt
│ │ ├── Theme.kt
│ │ └── Type.kt
│ └── res
│ ├── drawable-v24
│ └── ic_launcher_foreground.xml
│ ├── drawable
│ ├── ic_facebook.xml
│ ├── ic_instagram.xml
│ ├── ic_launcher_background.xml
│ ├── ic_outline_dark_mode_24.xml
│ ├── ic_pinterest.xml
│ ├── ic_twitter.xml
│ └── ic_whatsapp.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
│ ├── values
│ ├── colors.xml
│ ├── strings.xml
│ └── themes.xml
│ └── xml
│ ├── backup_rules.xml
│ └── data_extraction_rules.xml
├── build.gradle
├── draggablemenu
├── .gitignore
├── build.gradle
├── consumer-rules.pro
├── proguard-rules.pro
└── src
│ └── main
│ ├── AndroidManifest.xml
│ └── java
│ └── com
│ └── dokar
│ └── draggablemenu
│ ├── ClippedShadowSurface.kt
│ ├── DraggableMenu.kt
│ ├── DraggableMenuDefaults.kt
│ ├── DraggableMenuDsl.kt
│ ├── DraggableMenuState.kt
│ ├── Modifiers.kt
│ ├── Offset.ext.kt
│ └── ScaleIndication.kt
├── gradle.properties
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── images
├── sample_dark.gif
├── sample_press_anchor_to_drag.gif
└── sample_press_item_to_drag.gif
└── settings.gradle
/.github/dependabot.yaml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | updates:
4 | - package-ecosystem: "gradle"
5 | directory: "/"
6 | schedule:
7 | interval: "daily"
8 |
9 | - package-ecosystem: "github-actions"
10 | directory: "/"
11 | schedule:
12 | interval: "daily"
--------------------------------------------------------------------------------
/.github/workflows/android.yaml:
--------------------------------------------------------------------------------
1 | name: Android CI
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - uses: actions/checkout@v4
15 | - name: set up JDK 17
16 | uses: actions/setup-java@v4
17 | with:
18 | java-version: '17'
19 | distribution: 'adopt'
20 | cache: gradle
21 |
22 | - name: Grant execute permission for gradlew
23 | run: chmod +x gradlew
24 | - name: Run tests
25 | run: ./gradlew check
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea
5 | .DS_Store
6 | /build
7 | /captures
8 | .externalNativeBuild
9 | .cxx
10 | local.properties
11 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | Apache License
3 | Version 2.0, January 2004
4 | http://www.apache.org/licenses/
5 |
6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 |
8 | 1. Definitions.
9 |
10 | "License" shall mean the terms and conditions for use, reproduction,
11 | and distribution as defined by Sections 1 through 9 of this document.
12 |
13 | "Licensor" shall mean the copyright owner or entity authorized by
14 | the copyright owner that is granting the License.
15 |
16 | "Legal Entity" shall mean the union of the acting entity and all
17 | other entities that control, are controlled by, or are under common
18 | control with that entity. For the purposes of this definition,
19 | "control" means (i) the power, direct or indirect, to cause the
20 | direction or management of such entity, whether by contract or
21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 | outstanding shares, or (iii) beneficial ownership of such entity.
23 |
24 | "You" (or "Your") shall mean an individual or Legal Entity
25 | exercising permissions granted by this License.
26 |
27 | "Source" form shall mean the preferred form for making modifications,
28 | including but not limited to software source code, documentation
29 | source, and configuration files.
30 |
31 | "Object" form shall mean any form resulting from mechanical
32 | transformation or translation of a Source form, including but
33 | not limited to compiled object code, generated documentation,
34 | and conversions to other media types.
35 |
36 | "Work" shall mean the work of authorship, whether in Source or
37 | Object form, made available under the License, as indicated by a
38 | copyright notice that is included in or attached to the work
39 | (an example is provided in the Appendix below).
40 |
41 | "Derivative Works" shall mean any work, whether in Source or Object
42 | form, that is based on (or derived from) the Work and for which the
43 | editorial revisions, annotations, elaborations, or other modifications
44 | represent, as a whole, an original work of authorship. For the purposes
45 | of this License, Derivative Works shall not include works that remain
46 | separable from, or merely link (or bind by name) to the interfaces of,
47 | the Work and Derivative Works thereof.
48 |
49 | "Contribution" shall mean any work of authorship, including
50 | the original version of the Work and any modifications or additions
51 | to that Work or Derivative Works thereof, that is intentionally
52 | submitted to Licensor for inclusion in the Work by the copyright owner
53 | or by an individual or Legal Entity authorized to submit on behalf of
54 | the copyright owner. For the purposes of this definition, "submitted"
55 | means any form of electronic, verbal, or written communication sent
56 | to the Licensor or its representatives, including but not limited to
57 | communication on electronic mailing lists, source code control systems,
58 | and issue tracking systems that are managed by, or on behalf of, the
59 | Licensor for the purpose of discussing and improving the Work, but
60 | excluding communication that is conspicuously marked or otherwise
61 | designated in writing by the copyright owner as "Not a Contribution."
62 |
63 | "Contributor" shall mean Licensor and any individual or Legal Entity
64 | on behalf of whom a Contribution has been received by Licensor and
65 | subsequently incorporated within the Work.
66 |
67 | 2. Grant of Copyright License. Subject to the terms and conditions of
68 | this License, each Contributor hereby grants to You a perpetual,
69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 | copyright license to reproduce, prepare Derivative Works of,
71 | publicly display, publicly perform, sublicense, and distribute the
72 | Work and such Derivative Works in Source or Object form.
73 |
74 | 3. Grant of Patent License. Subject to the terms and conditions of
75 | this License, each Contributor hereby grants to You a perpetual,
76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 | (except as stated in this section) patent license to make, have made,
78 | use, offer to sell, sell, import, and otherwise transfer the Work,
79 | where such license applies only to those patent claims licensable
80 | by such Contributor that are necessarily infringed by their
81 | Contribution(s) alone or by combination of their Contribution(s)
82 | with the Work to which such Contribution(s) was submitted. If You
83 | institute patent litigation against any entity (including a
84 | cross-claim or counterclaim in a lawsuit) alleging that the Work
85 | or a Contribution incorporated within the Work constitutes direct
86 | or contributory patent infringement, then any patent licenses
87 | granted to You under this License for that Work shall terminate
88 | as of the date such litigation is filed.
89 |
90 | 4. Redistribution. You may reproduce and distribute copies of the
91 | Work or Derivative Works thereof in any medium, with or without
92 | modifications, and in Source or Object form, provided that You
93 | meet the following conditions:
94 |
95 | (a) You must give any other recipients of the Work or
96 | Derivative Works a copy of this License; and
97 |
98 | (b) You must cause any modified files to carry prominent notices
99 | stating that You changed the files; and
100 |
101 | (c) You must retain, in the Source form of any Derivative Works
102 | that You distribute, all copyright, patent, trademark, and
103 | attribution notices from the Source form of the Work,
104 | excluding those notices that do not pertain to any part of
105 | the Derivative Works; and
106 |
107 | (d) If the Work includes a "NOTICE" text file as part of its
108 | distribution, then any Derivative Works that You distribute must
109 | include a readable copy of the attribution notices contained
110 | within such NOTICE file, excluding those notices that do not
111 | pertain to any part of the Derivative Works, in at least one
112 | of the following places: within a NOTICE text file distributed
113 | as part of the Derivative Works; within the Source form or
114 | documentation, if provided along with the Derivative Works; or,
115 | within a display generated by the Derivative Works, if and
116 | wherever such third-party notices normally appear. The contents
117 | of the NOTICE file are for informational purposes only and
118 | do not modify the License. You may add Your own attribution
119 | notices within Derivative Works that You distribute, alongside
120 | or as an addendum to the NOTICE text from the Work, provided
121 | that such additional attribution notices cannot be construed
122 | as modifying the License.
123 |
124 | You may add Your own copyright statement to Your modifications and
125 | may provide additional or different license terms and conditions
126 | for use, reproduction, or distribution of Your modifications, or
127 | for any such Derivative Works as a whole, provided Your use,
128 | reproduction, and distribution of the Work otherwise complies with
129 | the conditions stated in this License.
130 |
131 | 5. Submission of Contributions. Unless You explicitly state otherwise,
132 | any Contribution intentionally submitted for inclusion in the Work
133 | by You to the Licensor shall be under the terms and conditions of
134 | this License, without any additional terms or conditions.
135 | Notwithstanding the above, nothing herein shall supersede or modify
136 | the terms of any separate license agreement you may have executed
137 | with Licensor regarding such Contributions.
138 |
139 | 6. Trademarks. This License does not grant permission to use the trade
140 | names, trademarks, service marks, or product names of the Licensor,
141 | except as required for reasonable and customary use in describing the
142 | origin of the Work and reproducing the content of the NOTICE file.
143 |
144 | 7. Disclaimer of Warranty. Unless required by applicable law or
145 | agreed to in writing, Licensor provides the Work (and each
146 | Contributor provides its Contributions) on an "AS IS" BASIS,
147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 | implied, including, without limitation, any warranties or conditions
149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 | PARTICULAR PURPOSE. You are solely responsible for determining the
151 | appropriateness of using or redistributing the Work and assume any
152 | risks associated with Your exercise of permissions under this License.
153 |
154 | 8. Limitation of Liability. In no event and under no legal theory,
155 | whether in tort (including negligence), contract, or otherwise,
156 | unless required by applicable law (such as deliberate and grossly
157 | negligent acts) or agreed to in writing, shall any Contributor be
158 | liable to You for damages, including any direct, indirect, special,
159 | incidental, or consequential damages of any character arising as a
160 | result of this License or out of the use or inability to use the
161 | Work (including but not limited to damages for loss of goodwill,
162 | work stoppage, computer failure or malfunction, or any and all
163 | other commercial damages or losses), even if such Contributor
164 | has been advised of the possibility of such damages.
165 |
166 | 9. Accepting Warranty or Additional Liability. While redistributing
167 | the Work or Derivative Works thereof, You may choose to offer,
168 | and charge a fee for, acceptance of support, warranty, indemnity,
169 | or other liability obligations and/or rights consistent with this
170 | License. However, in accepting such obligations, You may act only
171 | on Your own behalf and on Your sole responsibility, not on behalf
172 | of any other Contributor, and only if You agree to indemnify,
173 | defend, and hold each Contributor harmless for any liability
174 | incurred by, or claims asserted against, such Contributor by reason
175 | of your accepting any such warranty or additional liability.
176 |
177 | END OF TERMS AND CONDITIONS
178 |
179 | APPENDIX: How to apply the Apache License to your work.
180 |
181 | To apply the Apache License to your work, attach the following
182 | boilerplate notice, with the fields enclosed by brackets "[]"
183 | replaced with your own identifying information. (Don't include
184 | the brackets!) The text should be enclosed in the appropriate
185 | comment syntax for the file format. We also recommend that a
186 | file or class name and description of purpose be included on the
187 | same "printed page" as the copyright notice for easier
188 | identification within third-party archives.
189 |
190 | Copyright [yyyy] [name of copyright owner]
191 |
192 | Licensed under the Apache License, Version 2.0 (the "License");
193 | you may not use this file except in compliance with the License.
194 | You may obtain a copy of the License at
195 |
196 | http://www.apache.org/licenses/LICENSE-2.0
197 |
198 | Unless required by applicable law or agreed to in writing, software
199 | distributed under the License is distributed on an "AS IS" BASIS,
200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201 | See the License for the specific language governing permissions and
202 | limitations under the License.
203 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # A fluid draggable menu
2 |
3 | Inspired by the [original work](https://twitter.com/jmtrivedi/status/1610017363218563072?s=20&t=PP2YsTMOL5FYV4TWfka7cw) from Janum Trivedi ([jmtrivedi@twitter](https://twitter.com/jmtrivedi)), the draggable bouncy menu in Jetpack Compose is here.
4 |
5 |
Sample GIFs
6 |
7 | |  |  |  |
8 | |:-------------------------------------------:|:-----------------------------------------:|:---------------------------:|
9 | | Press anchor to drag | Press menu item to drag | Dark |
10 |
11 | The simplified sample code would be like this:
12 |
13 | ```kotlin
14 | @Composable
15 | fun MenuSample(modifier: Modifier = Modifier) {
16 | val state = rememberDraggableMenuState()
17 |
18 | Box(
19 | modifier = modifier
20 | .fillMaxSize()
21 | .draggableMenuContainer(state),
22 | ) {
23 | Box(modifier = Modifier.align(Alignment.Center)) {
24 | Icon(
25 | imageVector = Icons.Default.Share,
26 | contentDescription = null,
27 | modifier = Modifier.draggableMenuAnchor(state),
28 | )
29 |
30 | DraggableMenu(state = state, onItemSelected = {}) {
31 | items(5) {
32 | Text("Menu item: $it")
33 | }
34 | }
35 | }
36 | }
37 | }
38 | ```
39 |
40 | # License
41 |
42 | ```
43 | Copyright 2023 dokar3
44 |
45 | Licensed under the Apache License, Version 2.0 (the "License");
46 | you may not use this file except in compliance with the License.
47 | You may obtain a copy of the License at
48 |
49 | http://www.apache.org/licenses/LICENSE-2.0
50 |
51 | Unless required by applicable law or agreed to in writing, software
52 | distributed under the License is distributed on an "AS IS" BASIS,
53 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
54 | See the License for the specific language governing permissions and
55 | limitations under the License.
56 | ```
57 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/app/build.gradle:
--------------------------------------------------------------------------------
1 | plugins {
2 | id 'com.android.application'
3 | id 'org.jetbrains.kotlin.android'
4 | }
5 |
6 | android {
7 | namespace 'com.dokar.draggablemenu.sample'
8 | compileSdk 34
9 |
10 | defaultConfig {
11 | applicationId "com.dokar.draggablemenu.sample"
12 | minSdk 21
13 | targetSdk 34
14 | versionCode 1
15 | versionName "1.0"
16 |
17 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
18 | vectorDrawables {
19 | useSupportLibrary true
20 | }
21 | }
22 |
23 | buildTypes {
24 | release {
25 | minifyEnabled false
26 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
27 | signingConfig signingConfigs.debug
28 | }
29 | }
30 | compileOptions {
31 | sourceCompatibility JavaVersion.VERSION_11
32 | targetCompatibility JavaVersion.VERSION_11
33 | }
34 | kotlinOptions {
35 | jvmTarget = '11'
36 | }
37 | buildFeatures {
38 | compose true
39 | }
40 | composeOptions {
41 | kotlinCompilerExtensionVersion compiler_compiler
42 | }
43 | packaging {
44 | resources {
45 | excludes += '/META-INF/{AL2.0,LGPL2.1}'
46 | }
47 | }
48 | }
49 |
50 | dependencies {
51 |
52 | implementation project(':draggablemenu')
53 |
54 | implementation 'androidx.core:core-ktx:1.13.1'
55 | implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
56 | implementation 'androidx.activity:activity-compose:1.9.0'
57 | implementation platform("androidx.compose:compose-bom:$compose_bom")
58 | implementation 'androidx.compose.ui:ui'
59 | implementation 'androidx.compose.ui:ui-graphics'
60 | implementation 'androidx.compose.ui:ui-tooling-preview'
61 | implementation 'androidx.compose.material3:material3'
62 | testImplementation 'junit:junit:4.13.2'
63 | androidTestImplementation 'androidx.test.ext:junit:1.1.5'
64 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
65 | androidTestImplementation platform("androidx.compose:compose-bom:$compose_bom")
66 | androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
67 | debugImplementation 'androidx.compose.ui:ui-tooling'
68 | debugImplementation 'androidx.compose.ui:ui-test-manifest'
69 | }
--------------------------------------------------------------------------------
/app/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
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
15 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/app/src/main/java/com/dokar/draggablemenu/sample/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.dokar.draggablemenu.sample
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import androidx.compose.animation.core.Animatable
7 | import androidx.compose.foundation.clickable
8 | import androidx.compose.foundation.interaction.MutableInteractionSource
9 | import androidx.compose.foundation.isSystemInDarkTheme
10 | import androidx.compose.foundation.layout.Box
11 | import androidx.compose.foundation.layout.WindowInsets
12 | import androidx.compose.foundation.layout.fillMaxSize
13 | import androidx.compose.foundation.layout.offset
14 | import androidx.compose.foundation.layout.padding
15 | import androidx.compose.foundation.layout.statusBars
16 | import androidx.compose.foundation.shape.CircleShape
17 | import androidx.compose.material.icons.Icons
18 | import androidx.compose.material.icons.filled.MoreVert
19 | import androidx.compose.material.icons.filled.Share
20 | import androidx.compose.material3.Icon
21 | import androidx.compose.material3.MaterialTheme
22 | import androidx.compose.material3.Surface
23 | import androidx.compose.runtime.Composable
24 | import androidx.compose.runtime.LaunchedEffect
25 | import androidx.compose.runtime.getValue
26 | import androidx.compose.runtime.mutableStateOf
27 | import androidx.compose.runtime.remember
28 | import androidx.compose.runtime.setValue
29 | import androidx.compose.ui.Alignment
30 | import androidx.compose.ui.Modifier
31 | import androidx.compose.ui.draw.drawWithCache
32 | import androidx.compose.ui.draw.rotate
33 | import androidx.compose.ui.geometry.Offset
34 | import androidx.compose.ui.graphics.Brush
35 | import androidx.compose.ui.graphics.Color
36 | import androidx.compose.ui.layout.onSizeChanged
37 | import androidx.compose.ui.platform.LocalDensity
38 | import androidx.compose.ui.tooling.preview.Preview
39 | import androidx.compose.ui.unit.DpOffset
40 | import androidx.compose.ui.unit.IntOffset
41 | import androidx.compose.ui.unit.dp
42 | import com.dokar.draggablemenu.ClippedShadowSurface
43 | import com.dokar.draggablemenu.DraggableMenu
44 | import com.dokar.draggablemenu.DraggableMenuDefaults
45 | import com.dokar.draggablemenu.DraggableMenuState
46 | import com.dokar.draggablemenu.draggableMenuAnchor
47 | import com.dokar.draggablemenu.draggableMenuContainer
48 | import com.dokar.draggablemenu.rememberDraggableMenuState
49 | import com.dokar.draggablemenu.sample.theme.DraggableMenuTheme
50 |
51 | class MainActivity : ComponentActivity() {
52 | override fun onCreate(savedInstanceState: Bundle?) {
53 | super.onCreate(savedInstanceState)
54 | setContent {
55 | val defaultDarkTheme = isSystemInDarkTheme()
56 |
57 | var darkTheme by remember(defaultDarkTheme) { mutableStateOf(defaultDarkTheme) }
58 |
59 | DraggableMenuTheme(darkTheme = darkTheme) {
60 | Surface(
61 | modifier = Modifier.fillMaxSize(),
62 | color = MaterialTheme.colorScheme.background,
63 | ) {
64 | Sample(
65 | darkTheme = darkTheme,
66 | onChangeDarkTheme = { darkTheme = it },
67 | )
68 | }
69 | }
70 | }
71 | }
72 | }
73 |
74 | @Composable
75 | fun Sample(
76 | darkTheme: Boolean,
77 | onChangeDarkTheme: (Boolean) -> Unit,
78 | modifier: Modifier = Modifier
79 | ) {
80 | val menuState = rememberDraggableMenuState()
81 |
82 | var isExpanded by remember { mutableStateOf(false) }
83 |
84 | var anchorPosition by remember { mutableStateOf(AnchorPosition.Default) }
85 |
86 | var optionsHeight by remember { mutableStateOf(0) }
87 |
88 | SlidingUpLayout(
89 | isExpanded = isExpanded,
90 | onRequestChangeExpandedState = { isExpanded = it },
91 | expandable = {
92 | SampleOptions(
93 | anchorPosition = anchorPosition,
94 | onChangeAnchorPosition = { anchorPosition = it },
95 | darkTheme = darkTheme,
96 | onChangeDarkTheme = onChangeDarkTheme,
97 | modifier = Modifier.onSizeChanged { optionsHeight = it.height },
98 | )
99 | },
100 | modifier = modifier,
101 | swipeGestureEnabled = !menuState.isMenuShowing,
102 | ) {
103 | SampleContent(
104 | isOptionsShowing = isExpanded,
105 | optionsHeight = optionsHeight,
106 | menuState = menuState,
107 | onExpandClick = { isExpanded = !isExpanded },
108 | anchorPosition = anchorPosition,
109 | )
110 | }
111 | }
112 |
113 | @Composable
114 | fun SampleContent(
115 | isOptionsShowing: Boolean,
116 | optionsHeight: Int,
117 | menuState: DraggableMenuState,
118 | onExpandClick: () -> Unit,
119 | anchorPosition: AnchorPosition,
120 | modifier: Modifier = Modifier,
121 | ) {
122 | val menuItems = remember {
123 | listOf(
124 | SimpleMenuItem(
125 | icon = R.drawable.ic_twitter,
126 | backgroundColor = Color(0xFF1DA1F2),
127 | title = "Twitter",
128 | ),
129 | SimpleMenuItem(
130 | icon = R.drawable.ic_pinterest,
131 | backgroundColor = Color(0xFFE71D27),
132 | title = "Pinterest"
133 | ),
134 | SimpleMenuItem(
135 | icon = R.drawable.ic_facebook,
136 | backgroundColor = Color(0xFF1877F2),
137 | title = "Facebook"
138 | ),
139 | SimpleMenuItem(
140 | icon = R.drawable.ic_whatsapp,
141 | backgroundColor = Color(0xFF25D366),
142 | title = "Whatsapp"
143 | ),
144 | SimpleMenuItem(
145 | icon = R.drawable.ic_instagram,
146 | backgroundColor = Color(0xFfF70191),
147 | title = "Instagram"
148 | ),
149 | )
150 | }
151 |
152 | Box(
153 | modifier = modifier
154 | .fillMaxSize()
155 | .gradientBackground()
156 | .draggableMenuContainer(state = menuState)
157 | .padding(32.dp),
158 | ) {
159 | val anchorAlignment: Alignment
160 | val anchorOffsetY: Float
161 | val menuAlignment: Alignment
162 | val menuOffset: DpOffset
163 | when (anchorPosition) {
164 | AnchorPosition.Default -> {
165 | anchorAlignment = Alignment.Center
166 | anchorOffsetY = with(LocalDensity.current) { 150.dp.toPx() }
167 | menuAlignment = Alignment.BottomCenter
168 | menuOffset = DraggableMenuDefaults.Offset
169 | }
170 |
171 | AnchorPosition.TopEnd -> {
172 | anchorAlignment = Alignment.TopEnd
173 | anchorOffsetY = with(LocalDensity.current) {
174 | WindowInsets.statusBars.getTop(this).toFloat()
175 | }
176 | menuAlignment = Alignment.TopCenter
177 | menuOffset = DpOffset(0.dp, -DraggableMenuDefaults.Offset.y)
178 | }
179 |
180 | AnchorPosition.BottomStart -> {
181 | anchorAlignment = Alignment.BottomStart
182 | anchorOffsetY = 0f
183 | menuAlignment = Alignment.BottomCenter
184 | menuOffset = DraggableMenuDefaults.Offset
185 | }
186 | }
187 |
188 | fun calcFinalAnchorOffsetY(): Float {
189 | return if (isOptionsShowing && anchorPosition == AnchorPosition.TopEnd) {
190 | anchorOffsetY + optionsHeight
191 | } else {
192 | anchorOffsetY
193 | }
194 | }
195 |
196 | val finalAnchorOffsetY = remember(
197 | anchorOffsetY,
198 | optionsHeight,
199 | anchorPosition
200 | ) {
201 | Animatable(calcFinalAnchorOffsetY())
202 | }
203 |
204 | LaunchedEffect(finalAnchorOffsetY, isOptionsShowing) {
205 | val offsetY = calcFinalAnchorOffsetY()
206 | if (finalAnchorOffsetY.targetValue != offsetY) {
207 | finalAnchorOffsetY.animateTo(offsetY)
208 | }
209 | }
210 |
211 | Box(
212 | modifier = Modifier
213 | .offset { IntOffset(0, finalAnchorOffsetY.value.toInt()) }
214 | .align(anchorAlignment),
215 | ) {
216 | ClippedShadowSurface(
217 | shape = CircleShape,
218 | elevation = 4.dp,
219 | backgroundColor = DraggableMenuDefaults.backgroundColor(),
220 | modifier = Modifier.draggableMenuAnchor(state = menuState),
221 | ) {
222 | Icon(
223 | imageVector = Icons.Default.Share,
224 | contentDescription = null,
225 | modifier = Modifier.padding(12.dp),
226 | )
227 | }
228 |
229 | DraggableMenu(
230 | state = menuState,
231 | onItemSelected = {},
232 | alignment = menuAlignment,
233 | offset = menuOffset,
234 | ) {
235 | items(menuItems) {
236 | MenuItem(item = it)
237 | }
238 | }
239 | }
240 |
241 | Icon(
242 | imageVector = Icons.Default.MoreVert,
243 | contentDescription = null,
244 | modifier = Modifier
245 | .align(Alignment.BottomEnd)
246 | .rotate(90f)
247 | .clickable(
248 | interactionSource = remember { MutableInteractionSource() },
249 | indication = null,
250 | onClick = onExpandClick,
251 | ),
252 | tint = Color.White,
253 | )
254 | }
255 | }
256 |
257 | fun Modifier.gradientBackground(): Modifier {
258 | return drawWithCache {
259 | val brush = Brush.linearGradient(
260 | 0f to Color.Cyan,
261 | 1f to Color.Magenta,
262 | start = Offset(0f, 0f),
263 | end = Offset(size.width * 0.8f, size.height * 0.8f),
264 | )
265 | onDrawBehind {
266 | drawRect(brush = brush)
267 | }
268 | }
269 | }
270 |
271 | @Preview(showBackground = true)
272 | @Composable
273 | fun GreetingPreview() {
274 | DraggableMenuTheme {
275 | Sample(
276 | darkTheme = false,
277 | onChangeDarkTheme = {},
278 | )
279 | }
280 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/dokar/draggablemenu/sample/SampleMenuItem.kt:
--------------------------------------------------------------------------------
1 | package com.dokar.draggablemenu.sample
2 |
3 | import androidx.annotation.DrawableRes
4 | import androidx.compose.foundation.background
5 | import androidx.compose.foundation.layout.Row
6 | import androidx.compose.foundation.layout.Spacer
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.foundation.layout.size
9 | import androidx.compose.foundation.layout.width
10 | import androidx.compose.foundation.layout.widthIn
11 | import androidx.compose.foundation.shape.CircleShape
12 | import androidx.compose.material3.Icon
13 | import androidx.compose.material3.Text
14 | import androidx.compose.runtime.Composable
15 | import androidx.compose.runtime.Immutable
16 | import androidx.compose.ui.Alignment
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.draw.clip
19 | import androidx.compose.ui.graphics.Color
20 | import androidx.compose.ui.res.painterResource
21 | import androidx.compose.ui.unit.Dp
22 | import androidx.compose.ui.unit.dp
23 | import androidx.compose.ui.unit.sp
24 |
25 | @Composable
26 | fun MenuItem(
27 | item: SimpleMenuItem,
28 | modifier: Modifier = Modifier,
29 | minWidth: Dp = 260.dp,
30 | ) {
31 | Row(
32 | modifier = modifier
33 | .padding(horizontal = 16.dp, vertical = 12.dp)
34 | .widthIn(min = minWidth),
35 | verticalAlignment = Alignment.CenterVertically,
36 | ) {
37 | if (item.icon != 0) {
38 | Icon(
39 | painter = painterResource(item.icon),
40 | contentDescription = item.title,
41 | modifier = Modifier
42 | .size(32.dp)
43 | .clip(CircleShape)
44 | .background(item.backgroundColor.copy(alpha = 0.7f))
45 | .padding(6.dp),
46 | tint = Color.White,
47 | )
48 |
49 | Spacer(modifier = Modifier.width(16.dp))
50 | }
51 |
52 | Text(text = item.title, fontSize = 18.sp)
53 | }
54 | }
55 |
56 | @Immutable
57 | data class SimpleMenuItem(
58 | @DrawableRes
59 | val icon: Int,
60 | val backgroundColor: Color,
61 | val title: String,
62 | )
63 |
--------------------------------------------------------------------------------
/app/src/main/java/com/dokar/draggablemenu/sample/SampleOptions.kt:
--------------------------------------------------------------------------------
1 | package com.dokar.draggablemenu.sample
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.border
5 | import androidx.compose.foundation.clickable
6 | import androidx.compose.foundation.horizontalScroll
7 | import androidx.compose.foundation.interaction.MutableInteractionSource
8 | import androidx.compose.foundation.layout.Arrangement
9 | import androidx.compose.foundation.layout.Box
10 | import androidx.compose.foundation.layout.BoxScope
11 | import androidx.compose.foundation.layout.Column
12 | import androidx.compose.foundation.layout.IntrinsicSize
13 | import androidx.compose.foundation.layout.Row
14 | import androidx.compose.foundation.layout.Spacer
15 | import androidx.compose.foundation.layout.fillMaxHeight
16 | import androidx.compose.foundation.layout.fillMaxWidth
17 | import androidx.compose.foundation.layout.height
18 | import androidx.compose.foundation.layout.padding
19 | import androidx.compose.foundation.layout.size
20 | import androidx.compose.foundation.layout.width
21 | import androidx.compose.foundation.rememberScrollState
22 | import androidx.compose.foundation.shape.CircleShape
23 | import androidx.compose.material3.Icon
24 | import androidx.compose.material3.MaterialTheme
25 | import androidx.compose.material3.Text
26 | import androidx.compose.runtime.Composable
27 | import androidx.compose.runtime.remember
28 | import androidx.compose.ui.Alignment
29 | import androidx.compose.ui.Modifier
30 | import androidx.compose.ui.draw.clip
31 | import androidx.compose.ui.graphics.Color
32 | import androidx.compose.ui.res.painterResource
33 | import androidx.compose.ui.unit.dp
34 | import androidx.compose.ui.unit.sp
35 |
36 | enum class AnchorPosition {
37 | Default,
38 | TopEnd,
39 | BottomStart,
40 | }
41 |
42 | @Composable
43 | fun SampleOptions(
44 | anchorPosition: AnchorPosition,
45 | onChangeAnchorPosition: (AnchorPosition) -> Unit,
46 | darkTheme: Boolean,
47 | onChangeDarkTheme: (Boolean) -> Unit,
48 | modifier: Modifier = Modifier,
49 | ) {
50 | Row(
51 | modifier = modifier
52 | .fillMaxWidth()
53 | .height(IntrinsicSize.Min)
54 | .background(Color(0xff333333))
55 | .padding(16.dp)
56 | .horizontalScroll(state = rememberScrollState()),
57 | horizontalArrangement = Arrangement.spacedBy(8.dp),
58 | ) {
59 | for (pos in AnchorPosition.values()) {
60 | val iconAlignment: Alignment
61 | val title: String
62 | when (pos) {
63 | AnchorPosition.Default -> {
64 | iconAlignment = Alignment.Center
65 | title = "Default"
66 | }
67 |
68 | AnchorPosition.TopEnd -> {
69 | iconAlignment = Alignment.TopEnd
70 | title = "Top end"
71 | }
72 |
73 | AnchorPosition.BottomStart -> {
74 | iconAlignment = Alignment.BottomStart
75 | title = "Bottom start"
76 | }
77 | }
78 | SampleOption(
79 | isSelected = pos == anchorPosition,
80 | onSelect = { onChangeAnchorPosition(pos) },
81 | icon = {
82 | Spacer(
83 | modifier = Modifier
84 | .padding(6.dp)
85 | .size(12.dp)
86 | .background(
87 | color = Color.White,
88 | shape = CircleShape,
89 | ),
90 | )
91 | },
92 | iconAlignment = iconAlignment,
93 | title = title,
94 | )
95 | }
96 |
97 | Spacer(
98 | modifier = Modifier
99 | .width(1.dp)
100 | .fillMaxHeight()
101 | .background(Color.White.copy(alpha = 0.5f))
102 | )
103 |
104 | SampleOption(
105 | isSelected = darkTheme,
106 | onSelect = { onChangeDarkTheme(!darkTheme) },
107 | icon = {
108 | Icon(
109 | painter = painterResource(R.drawable.ic_outline_dark_mode_24),
110 | contentDescription = null,
111 | tint = Color.White,
112 | )
113 | },
114 | title = "Dark theme",
115 | )
116 | }
117 | }
118 |
119 | @Composable
120 | private fun SampleOption(
121 | isSelected: Boolean,
122 | onSelect: () -> Unit,
123 | icon: @Composable BoxScope.() -> Unit,
124 | title: String,
125 | modifier: Modifier = Modifier,
126 | iconAlignment: Alignment = Alignment.Center,
127 | ) {
128 | Column(
129 | modifier = modifier
130 | .clickable(
131 | interactionSource = remember { MutableInteractionSource() },
132 | indication = null,
133 | onClick = onSelect,
134 | ),
135 | horizontalAlignment = Alignment.CenterHorizontally,
136 | ) {
137 | Box(
138 | modifier = Modifier
139 | .size(36.dp)
140 | .clip(MaterialTheme.shapes.small)
141 | .background(Color.White.copy(alpha = 0.1f))
142 | .border(
143 | shape = MaterialTheme.shapes.small,
144 | color = if (isSelected) {
145 | MaterialTheme.colorScheme.primary
146 | } else {
147 | Color.White.copy(alpha = 0.3f)
148 | },
149 | width = 1.dp,
150 | ),
151 | contentAlignment = iconAlignment,
152 | ) {
153 | icon()
154 | }
155 |
156 | Text(
157 | text = title,
158 | fontSize = 13.sp,
159 | color = Color.White,
160 | )
161 | }
162 | }
163 |
--------------------------------------------------------------------------------
/app/src/main/java/com/dokar/draggablemenu/sample/SlidingUpLayout.kt:
--------------------------------------------------------------------------------
1 | package com.dokar.draggablemenu.sample
2 |
3 | import androidx.compose.animation.core.Animatable
4 | import androidx.compose.animation.core.AnimationSpec
5 | import androidx.compose.animation.core.spring
6 | import androidx.compose.foundation.gestures.detectVerticalDragGestures
7 | import androidx.compose.foundation.layout.Box
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.runtime.LaunchedEffect
10 | import androidx.compose.runtime.remember
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.input.pointer.pointerInput
13 | import androidx.compose.ui.layout.Layout
14 | import androidx.compose.ui.unit.Constraints
15 | import androidx.compose.ui.unit.dp
16 |
17 | @Composable
18 | fun SlidingUpLayout(
19 | isExpanded: Boolean,
20 | onRequestChangeExpandedState: (Boolean) -> Unit,
21 | expandable: @Composable () -> Unit,
22 | modifier: Modifier = Modifier,
23 | animationSpec: AnimationSpec = spring(),
24 | swipeGestureEnabled: Boolean = true,
25 | content: @Composable () -> Unit,
26 | ) {
27 | val expandProgress = remember { Animatable(0f) }
28 |
29 | LaunchedEffect(isExpanded) {
30 | expandProgress.stop()
31 | expandProgress.animateTo(
32 | targetValue = if (isExpanded) 1f else 0f,
33 | animationSpec = animationSpec,
34 | )
35 | }
36 |
37 | Layout(
38 | content = {
39 | Box { expandable() }
40 | Box { content() }
41 | },
42 | modifier = modifier
43 | .pointerInput(isExpanded, swipeGestureEnabled, onRequestChangeExpandedState) {
44 | var totalDragAmount = 0f
45 | detectVerticalDragGestures(
46 | onDragStart = { totalDragAmount = 0f },
47 | onDragEnd = {
48 | if (!swipeGestureEnabled) {
49 | return@detectVerticalDragGestures
50 | }
51 | val threshold = 56.dp.toPx()
52 | if (isExpanded && totalDragAmount > threshold) {
53 | onRequestChangeExpandedState(false)
54 | } else if (!isExpanded && totalDragAmount < -threshold) {
55 | onRequestChangeExpandedState(true)
56 | }
57 | },
58 | onDragCancel = {},
59 | onVerticalDrag = { _, dragAmount -> totalDragAmount += dragAmount },
60 | )
61 | },
62 | ) { measurables, constraints ->
63 | val expandablePlaceable = measurables.first().measure(
64 | Constraints(
65 | minWidth = constraints.minWidth,
66 | maxWidth = constraints.maxWidth,
67 | )
68 | )
69 | val contentPlaceable = measurables[1].measure(constraints)
70 |
71 | val expandableHeight = expandablePlaceable.height
72 |
73 | layout(contentPlaceable.width, contentPlaceable.height) {
74 | val contentOffsetY = -(expandableHeight * expandProgress.value).toInt()
75 | contentPlaceable.place(0, contentOffsetY)
76 |
77 | if (expandProgress.value > 0f) {
78 | val expandableOffsetY = contentPlaceable.height -
79 | (expandableHeight * expandProgress.value).toInt()
80 | expandablePlaceable.place(0, expandableOffsetY)
81 | }
82 | }
83 | }
84 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/dokar/draggablemenu/sample/theme/Color.kt:
--------------------------------------------------------------------------------
1 | package com.dokar.draggablemenu.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)
--------------------------------------------------------------------------------
/app/src/main/java/com/dokar/draggablemenu/sample/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package com.dokar.draggablemenu.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 |
17 | private val DarkColorScheme = darkColorScheme(
18 | primary = Purple80,
19 | secondary = PurpleGrey80,
20 | tertiary = Pink80
21 | )
22 |
23 | private val LightColorScheme = lightColorScheme(
24 | primary = Purple40,
25 | secondary = PurpleGrey40,
26 | tertiary = Pink40
27 |
28 | /* Other default colors to override
29 | background = Color(0xFFFFFBFE),
30 | surface = Color(0xFFFFFBFE),
31 | onPrimary = Color.White,
32 | onSecondary = Color.White,
33 | onTertiary = Color.White,
34 | onBackground = Color(0xFF1C1B1F),
35 | onSurface = Color(0xFF1C1B1F),
36 | */
37 | )
38 |
39 | @Composable
40 | fun DraggableMenuTheme(
41 | darkTheme: Boolean = isSystemInDarkTheme(),
42 | // Dynamic color is available on Android 12+
43 | dynamicColor: Boolean = true,
44 | content: @Composable () -> Unit
45 | ) {
46 | val colorScheme = when {
47 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
48 | val context = LocalContext.current
49 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
50 | }
51 |
52 | darkTheme -> DarkColorScheme
53 | else -> LightColorScheme
54 | }
55 | val view = LocalView.current
56 | if (!view.isInEditMode) {
57 | SideEffect {
58 | val window = (view.context as Activity).window
59 | window.statusBarColor = colorScheme.primary.toArgb()
60 | }
61 | }
62 |
63 | MaterialTheme(
64 | colorScheme = colorScheme,
65 | typography = Typography,
66 | content = content
67 | )
68 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/dokar/draggablemenu/sample/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package com.dokar.draggablemenu.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 | /* Other default text styles to override
19 | titleLarge = TextStyle(
20 | fontFamily = FontFamily.Default,
21 | fontWeight = FontWeight.Normal,
22 | fontSize = 22.sp,
23 | lineHeight = 28.sp,
24 | letterSpacing = 0.sp
25 | ),
26 | labelSmall = TextStyle(
27 | fontFamily = FontFamily.Default,
28 | fontWeight = FontWeight.Medium,
29 | fontSize = 11.sp,
30 | lineHeight = 16.sp,
31 | letterSpacing = 0.5.sp
32 | )
33 | */
34 | )
--------------------------------------------------------------------------------
/app/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
15 |
18 |
21 |
22 |
23 |
24 |
30 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_facebook.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_instagram.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/app/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 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_outline_dark_mode_24.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_pinterest.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_twitter.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_whatsapp.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dokar3/draggable-menu/48bfc175cfb5bb23d31e0bea291a731303fd23b0/app/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dokar3/draggable-menu/48bfc175cfb5bb23d31e0bea291a731303fd23b0/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dokar3/draggable-menu/48bfc175cfb5bb23d31e0bea291a731303fd23b0/app/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dokar3/draggable-menu/48bfc175cfb5bb23d31e0bea291a731303fd23b0/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dokar3/draggable-menu/48bfc175cfb5bb23d31e0bea291a731303fd23b0/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dokar3/draggable-menu/48bfc175cfb5bb23d31e0bea291a731303fd23b0/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dokar3/draggable-menu/48bfc175cfb5bb23d31e0bea291a731303fd23b0/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dokar3/draggable-menu/48bfc175cfb5bb23d31e0bea291a731303fd23b0/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dokar3/draggable-menu/48bfc175cfb5bb23d31e0bea291a731303fd23b0/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dokar3/draggable-menu/48bfc175cfb5bb23d31e0bea291a731303fd23b0/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Draggable menu
3 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/build.gradle:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 | plugins {
3 | id 'com.android.application' version '8.3.2' apply false
4 | id 'com.android.library' version '8.3.2' apply false
5 | id 'org.jetbrains.kotlin.android' version '1.9.23' apply false
6 |
7 | }
8 |
9 | ext {
10 | compiler_compiler = "1.5.11"
11 | compose_bom = "2024.05.00"
12 | }
13 |
--------------------------------------------------------------------------------
/draggablemenu/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/draggablemenu/build.gradle:
--------------------------------------------------------------------------------
1 | plugins {
2 | id 'com.android.library'
3 | id 'org.jetbrains.kotlin.android'
4 | }
5 |
6 | android {
7 | namespace 'com.dokar.draggablemenu'
8 | compileSdk 33
9 |
10 | defaultConfig {
11 | minSdk 21
12 | targetSdk 33
13 |
14 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
15 | consumerProguardFiles "consumer-rules.pro"
16 | }
17 |
18 | buildTypes {
19 | release {
20 | minifyEnabled false
21 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
22 | }
23 | }
24 | compileOptions {
25 | sourceCompatibility JavaVersion.VERSION_11
26 | targetCompatibility JavaVersion.VERSION_11
27 | }
28 | kotlinOptions {
29 | jvmTarget = '11'
30 | }
31 | buildFeatures {
32 | compose true
33 | }
34 | composeOptions {
35 | kotlinCompilerExtensionVersion compiler_compiler
36 | }
37 | }
38 |
39 | dependencies {
40 |
41 | implementation platform("androidx.compose:compose-bom:$compose_bom")
42 | implementation 'androidx.compose.ui:ui'
43 | implementation 'androidx.compose.ui:ui-graphics'
44 | implementation 'androidx.compose.ui:ui-tooling-preview'
45 | implementation 'androidx.compose.material3:material3'
46 | testImplementation 'junit:junit:4.13.2'
47 | androidTestImplementation 'androidx.test.ext:junit:1.1.5'
48 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
49 | androidTestImplementation platform("androidx.compose:compose-bom:$compose_bom")
50 | androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
51 | debugImplementation 'androidx.compose.ui:ui-tooling'
52 | debugImplementation 'androidx.compose.ui:ui-test-manifest'
53 | }
--------------------------------------------------------------------------------
/draggablemenu/consumer-rules.pro:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dokar3/draggable-menu/48bfc175cfb5bb23d31e0bea291a731303fd23b0/draggablemenu/consumer-rules.pro
--------------------------------------------------------------------------------
/draggablemenu/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
--------------------------------------------------------------------------------
/draggablemenu/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/draggablemenu/src/main/java/com/dokar/draggablemenu/ClippedShadowSurface.kt:
--------------------------------------------------------------------------------
1 | package com.dokar.draggablemenu
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.Box
5 | import androidx.compose.foundation.layout.fillMaxSize
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.ui.Modifier
8 | import androidx.compose.ui.draw.drawWithCache
9 | import androidx.compose.ui.draw.shadow
10 | import androidx.compose.ui.graphics.ClipOp
11 | import androidx.compose.ui.graphics.Color
12 | import androidx.compose.ui.graphics.DefaultShadowColor
13 | import androidx.compose.ui.graphics.Path
14 | import androidx.compose.ui.graphics.Shape
15 | import androidx.compose.ui.graphics.addOutline
16 | import androidx.compose.ui.graphics.drawscope.clipPath
17 | import androidx.compose.ui.layout.Layout
18 | import androidx.compose.ui.unit.Dp
19 | import kotlin.math.max
20 |
21 | @Composable
22 | fun ClippedShadowSurface(
23 | shape: Shape,
24 | elevation: Dp,
25 | backgroundColor: Color,
26 | modifier: Modifier = Modifier,
27 | ambientColor: Color = DefaultShadowColor,
28 | spotColor: Color = DefaultShadowColor,
29 | ) {
30 | ClippedShadowSurface(
31 | shape = shape,
32 | elevation = elevation,
33 | backgroundColor = backgroundColor,
34 | modifier = modifier,
35 | ambientColor = ambientColor,
36 | spotColor = spotColor,
37 | content = {},
38 | )
39 | }
40 |
41 | // Original idea: https://gist.github.com/zed-alpha/3dc931720292c1f3ff31fa6a130f52cd
42 | @Composable
43 | fun ClippedShadowSurface(
44 | shape: Shape,
45 | elevation: Dp,
46 | backgroundColor: Color,
47 | modifier: Modifier = Modifier,
48 | ambientColor: Color = DefaultShadowColor,
49 | spotColor: Color = DefaultShadowColor,
50 | content: @Composable () -> Unit,
51 | ) {
52 | Layout(
53 | content = {
54 | Box(
55 | modifier = Modifier
56 | .fillMaxSize()
57 | .background(shape = shape, color = backgroundColor)
58 | .drawWithCache {
59 | val path = Path()
60 | val outline = shape.createOutline(
61 | size = size,
62 | layoutDirection = layoutDirection,
63 | density = this
64 | )
65 | path.addOutline(outline)
66 | onDrawWithContent {
67 | clipPath(path = path, clipOp = ClipOp.Difference) {
68 | this@onDrawWithContent.drawContent()
69 | }
70 | }
71 | }
72 | .shadow(
73 | elevation = elevation,
74 | shape = shape,
75 | ambientColor = ambientColor,
76 | spotColor = spotColor
77 | ),
78 | )
79 |
80 | content()
81 | },
82 | modifier = modifier,
83 | ) { measurables, constraints ->
84 | if (measurables.size == 1) {
85 | val shadowBoxPlaceable = measurables.first().measure(constraints)
86 | return@Layout layout(shadowBoxPlaceable.width, shadowBoxPlaceable.height) {
87 | shadowBoxPlaceable.place(0, 0)
88 | }
89 | }
90 |
91 | val placeables = List(measurables.size - 1) {
92 | measurables[it + 1].measure(constraints)
93 | }
94 |
95 | var width = placeables.first().width
96 | var height = placeables.first().height
97 | for (i in 1 until placeables.size) {
98 | width = max(width, placeables[i].width)
99 | height = max(width, placeables[i].height)
100 | }
101 |
102 | val shadowBoxPlaceable = measurables.first()
103 | .measure(constraints.copy(maxWidth = width, maxHeight = height))
104 |
105 | layout(width, height) {
106 | shadowBoxPlaceable.place(0, 0)
107 | placeables.forEach { it.place(0, 0) }
108 | }
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/draggablemenu/src/main/java/com/dokar/draggablemenu/DraggableMenu.kt:
--------------------------------------------------------------------------------
1 | package com.dokar.draggablemenu
2 |
3 | import androidx.compose.animation.core.Animatable
4 | import androidx.compose.animation.core.MutableTransitionState
5 | import androidx.compose.animation.core.Spring
6 | import androidx.compose.animation.core.animateFloatAsState
7 | import androidx.compose.animation.core.spring
8 | import androidx.compose.foundation.Indication
9 | import androidx.compose.foundation.indication
10 | import androidx.compose.foundation.interaction.MutableInteractionSource
11 | import androidx.compose.foundation.layout.Box
12 | import androidx.compose.foundation.layout.BoxScope
13 | import androidx.compose.foundation.layout.Column
14 | import androidx.compose.foundation.layout.IntrinsicSize
15 | import androidx.compose.foundation.layout.PaddingValues
16 | import androidx.compose.foundation.layout.fillMaxSize
17 | import androidx.compose.foundation.layout.fillMaxWidth
18 | import androidx.compose.foundation.layout.height
19 | import androidx.compose.foundation.layout.padding
20 | import androidx.compose.foundation.layout.width
21 | import androidx.compose.material3.MaterialTheme
22 | import androidx.compose.runtime.Composable
23 | import androidx.compose.runtime.LaunchedEffect
24 | import androidx.compose.runtime.SideEffect
25 | import androidx.compose.runtime.derivedStateOf
26 | import androidx.compose.runtime.getValue
27 | import androidx.compose.runtime.remember
28 | import androidx.compose.runtime.rememberUpdatedState
29 | import androidx.compose.runtime.snapshotFlow
30 | import androidx.compose.ui.Alignment
31 | import androidx.compose.ui.Modifier
32 | import androidx.compose.ui.draw.clip
33 | import androidx.compose.ui.graphics.Color
34 | import androidx.compose.ui.graphics.Shape
35 | import androidx.compose.ui.graphics.TransformOrigin
36 | import androidx.compose.ui.graphics.graphicsLayer
37 | import androidx.compose.ui.hapticfeedback.HapticFeedbackType
38 | import androidx.compose.ui.layout.onGloballyPositioned
39 | import androidx.compose.ui.platform.LocalDensity
40 | import androidx.compose.ui.platform.LocalHapticFeedback
41 | import androidx.compose.ui.platform.LocalView
42 | import androidx.compose.ui.unit.Dp
43 | import androidx.compose.ui.unit.DpOffset
44 | import androidx.compose.ui.unit.IntOffset
45 | import androidx.compose.ui.unit.dp
46 | import androidx.compose.ui.unit.toOffset
47 | import androidx.compose.ui.window.Popup
48 | import androidx.compose.ui.window.PopupProperties
49 | import kotlinx.coroutines.flow.distinctUntilChanged
50 | import kotlinx.coroutines.flow.filter
51 | import kotlinx.coroutines.flow.map
52 | import kotlinx.coroutines.launch
53 |
54 | @Composable
55 | fun DraggableMenu(
56 | state: DraggableMenuState,
57 | onItemSelected: (index: Int) -> Unit,
58 | modifier: Modifier = Modifier,
59 | alignment: Alignment = Alignment.BottomCenter,
60 | offset: DpOffset = DraggableMenuDefaults.Offset,
61 | shape: Shape = MaterialTheme.shapes.large,
62 | hoverBarShape: Shape = MaterialTheme.shapes.large,
63 | backgroundColor: Color = DraggableMenuDefaults.backgroundColor(),
64 | hoverBarBackgroundColor: Color = DraggableMenuDefaults.hoverBarBackground(),
65 | elevation: Dp = DraggableMenuDefaults.Elevation,
66 | properties: PopupProperties = PopupProperties(),
67 | itemIndication: Indication? = rememberScaleIndication(pressedScale = 0.9f),
68 | content: DraggableMenuScope.() -> Unit,
69 | ) {
70 | val density = LocalDensity.current
71 |
72 | val hapticFeedback = LocalHapticFeedback.current
73 |
74 | val visibleState = remember { MutableTransitionState(false) }
75 |
76 | val popupOffset = with(density) {
77 | IntOffset(offset.x.roundToPx(), offset.y.roundToPx())
78 | }
79 |
80 | val contentPadding = PaddingValues(
81 | horizontal = 24.dp,
82 | vertical = 16.dp,
83 | )
84 |
85 | SideEffect {
86 | state.menuContentPadding = contentPadding
87 | state.onItemSelected = onItemSelected
88 | }
89 |
90 | LaunchedEffect(state, visibleState, hapticFeedback) {
91 | launch {
92 | snapshotFlow { state.isMenuShowing }
93 | .distinctUntilChanged()
94 | .collect { isMenuShowing ->
95 | visibleState.targetState = isMenuShowing
96 | if (isMenuShowing) {
97 | hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
98 | }
99 | }
100 | }
101 | launch {
102 | snapshotFlow { state.hoveredItem }
103 | .filter { it.index >= 0 }
104 | .map { it.index }
105 | .distinctUntilChanged()
106 | .collect {
107 | hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
108 | }
109 | }
110 | }
111 |
112 | if (visibleState.currentState || visibleState.targetState || !visibleState.isIdle) {
113 | Popup(
114 | alignment = alignment,
115 | offset = popupOffset,
116 | onDismissRequest = { state.hideMenu() },
117 | properties = properties,
118 | ) {
119 | val view = LocalView.current
120 |
121 | DraggableMenuContent(
122 | state = state,
123 | modifier = modifier
124 | .onGloballyPositioned {
125 | state.menuCoordinates = it
126 | state.menuSize = it.size
127 | val viewPos = intArrayOf(0, 0)
128 | view.getLocationOnScreen(viewPos)
129 | state.menuPosOnScreen = IntOffset(viewPos[0], viewPos[1]).toOffset()
130 | }
131 | .handleMenuTapAndDragGestures(state)
132 | .stretchEffect(state)
133 | .animateMenuEnterExit(
134 | menuState = state,
135 | visibleState = visibleState,
136 | ),
137 | shape = shape,
138 | hoverBarShape = hoverBarShape,
139 | backgroundColor = backgroundColor,
140 | hoverBarBackgroundColor = hoverBarBackgroundColor,
141 | elevation = elevation,
142 | contentPadding = contentPadding,
143 | itemIndication = itemIndication,
144 | content = content,
145 | )
146 | }
147 | }
148 | }
149 |
150 | @Composable
151 | private fun DraggableMenuContent(
152 | state: DraggableMenuState,
153 | shape: Shape,
154 | hoverBarShape: Shape,
155 | backgroundColor: Color,
156 | hoverBarBackgroundColor: Color,
157 | elevation: Dp,
158 | contentPadding: PaddingValues,
159 | itemIndication: Indication?,
160 | modifier: Modifier = Modifier,
161 | content: DraggableMenuScope.() -> Unit,
162 | ) {
163 | val density = LocalDensity.current
164 |
165 | val latestContent = rememberUpdatedState(content)
166 |
167 | val itemProvider by remember(state) {
168 | derivedStateOf {
169 | val provider = DraggableMenuItemProvider().also(latestContent.value)
170 | state.setItemProvider(provider)
171 | provider
172 | }
173 | }
174 |
175 | Box(
176 | modifier = modifier
177 | .width(IntrinsicSize.Min)
178 | .height(IntrinsicSize.Min),
179 | ) {
180 | ClippedShadowSurface(
181 | shape = shape,
182 | elevation = elevation,
183 | backgroundColor = backgroundColor,
184 | modifier = Modifier
185 | .fillMaxSize()
186 | .padding(contentPadding),
187 | )
188 |
189 | val showHoverBar by remember(state) {
190 | derivedStateOf { !state.hoveredItem.isNone() }
191 | }
192 |
193 | val hoverBarAnimValue by animateFloatAsState(
194 | targetValue = if (showHoverBar) 1f else 0f,
195 | animationSpec = spring(
196 | dampingRatio = Spring.DampingRatioLowBouncy,
197 | stiffness = Spring.StiffnessMediumLow,
198 | ),
199 | label = "HoverBar",
200 | )
201 |
202 | val hoverHeight by remember {
203 | derivedStateOf {
204 | with(density) { state.hoverBarHeight.value.toDp() }
205 | }
206 | }
207 |
208 | val hoverBarShadowColor = Color.Black.copy(alpha = 0.5f)
209 |
210 | ClippedShadowSurface(
211 | shape = hoverBarShape,
212 | elevation = 12.dp,
213 | ambientColor = hoverBarShadowColor,
214 | spotColor = hoverBarShadowColor,
215 | backgroundColor = hoverBarBackgroundColor,
216 | modifier = Modifier
217 | .fillMaxWidth()
218 | .height(hoverHeight)
219 | .padding(horizontal = 8.dp)
220 | .graphicsLayer {
221 | transformOrigin = TransformOrigin.Center
222 | translationY = state.hoverBarOffset.value
223 | scaleX = hoverBarAnimValue
224 | scaleY = hoverBarAnimValue
225 | alpha = hoverBarAnimValue.coerceIn(0f, 1f)
226 | },
227 | )
228 |
229 | Column(
230 | modifier = Modifier
231 | .width(IntrinsicSize.Max)
232 | .padding(contentPadding)
233 | .clip(shape),
234 | ) {
235 | for ((index, itemContent) in itemProvider.itemsContents.withIndex()) {
236 | val isHovered = index == state.hoveredItem.index
237 |
238 | val offsetY = remember { Animatable(0f) }
239 |
240 | LaunchedEffect(isHovered) {
241 | if (!isHovered) {
242 | offsetY.animateTo(0f)
243 | }
244 | }
245 |
246 | LaunchedEffect(state) {
247 | snapshotFlow { state.hoveredItem }
248 | .filter { it.index == index }
249 | .collect {
250 | if (it.height <= 0) return@collect
251 | val halfHeight = it.height / 2f
252 | val fraction = if (it.pointerYToTop <= halfHeight) {
253 | (it.pointerYToTop - halfHeight) / halfHeight / 4f
254 | } else {
255 | (it.pointerYToTop - halfHeight) / halfHeight
256 | }
257 | offsetY.snapTo(fraction.coerceIn(-1f, 1f) * it.height / 10f)
258 | }
259 | }
260 |
261 | val interactionSource = remember(state, index) {
262 | MutableInteractionSource().also {
263 | state.updateItemInteractionSource(index, it)
264 | }
265 | }
266 |
267 | DraggableMenuItemWrapper(
268 | isHovered = isHovered,
269 | modifier = Modifier
270 | .onGloballyPositioned { state.updateItemCoordinates(index, it) }
271 | .graphicsLayer { translationY = offsetY.value }
272 | .indication(
273 | interactionSource = interactionSource,
274 | indication = itemIndication,
275 | ),
276 | content = { itemContent() },
277 | )
278 | }
279 | }
280 | }
281 | }
282 |
283 | @Composable
284 | private fun DraggableMenuItemWrapper(
285 | isHovered: Boolean,
286 | modifier: Modifier = Modifier,
287 | content: @Composable BoxScope.() -> Unit,
288 | ) {
289 | val scale = animateFloatAsState(
290 | targetValue = if (isHovered) 1.1f else 1f,
291 | animationSpec = if (isHovered) {
292 | spring()
293 | } else {
294 | spring(
295 | dampingRatio = Spring.DampingRatioLowBouncy,
296 | stiffness = Spring.StiffnessLow,
297 | )
298 | },
299 | label = "Scale",
300 | )
301 | Box(
302 | modifier = modifier.graphicsLayer {
303 | scaleX = scale.value
304 | scaleY = scale.value
305 | transformOrigin = TransformOrigin.Center
306 | },
307 | content = content,
308 | )
309 | }
310 |
--------------------------------------------------------------------------------
/draggablemenu/src/main/java/com/dokar/draggablemenu/DraggableMenuDefaults.kt:
--------------------------------------------------------------------------------
1 | package com.dokar.draggablemenu
2 |
3 | import androidx.compose.material3.MaterialTheme
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.runtime.remember
6 | import androidx.compose.ui.graphics.Color
7 | import androidx.compose.ui.graphics.compositeOver
8 | import androidx.compose.ui.unit.DpOffset
9 | import androidx.compose.ui.unit.dp
10 |
11 | object DraggableMenuDefaults {
12 | val Elevation = 12.dp
13 |
14 | val Offset = DpOffset(0.dp, -(48.dp))
15 |
16 | @Composable
17 | fun backgroundColor(): Color {
18 | return MaterialTheme.colorScheme.surface.copy(alpha = 0.8f)
19 | }
20 |
21 | @Composable
22 | fun hoverBarBackground(): Color {
23 | val surface = MaterialTheme.colorScheme.surface
24 | return remember(surface) {
25 | Color.White.copy(alpha = 0.3f).compositeOver(surface)
26 | }
27 | }
28 | }
--------------------------------------------------------------------------------
/draggablemenu/src/main/java/com/dokar/draggablemenu/DraggableMenuDsl.kt:
--------------------------------------------------------------------------------
1 | package com.dokar.draggablemenu
2 |
3 | import androidx.compose.runtime.Composable
4 |
5 | interface DraggableMenuScope {
6 | fun item(content: @Composable () -> Unit)
7 |
8 | fun items(count: Int, itemContent: @Composable (Int) -> Unit)
9 |
10 | fun items(items: Iterable, itemContent: @Composable (T) -> Unit)
11 | }
12 |
13 | internal class DraggableMenuItemProvider : DraggableMenuScope {
14 | private val _itemContents: MutableList<@Composable () -> Unit> = mutableListOf()
15 | val itemsContents: List<@Composable () -> Unit> = _itemContents
16 |
17 | override fun item(content: @Composable () -> Unit) {
18 | _itemContents.add(content)
19 | }
20 |
21 | override fun items(count: Int, itemContent: @Composable (Int) -> Unit) {
22 | for (i in 0 until count) {
23 | _itemContents.add {
24 | itemContent(i)
25 | }
26 | }
27 | }
28 |
29 | override fun items(items: Iterable, itemContent: @Composable (T) -> Unit) {
30 | for (item in items) {
31 | _itemContents.add {
32 | itemContent(item)
33 | }
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/draggablemenu/src/main/java/com/dokar/draggablemenu/DraggableMenuState.kt:
--------------------------------------------------------------------------------
1 | package com.dokar.draggablemenu
2 |
3 | import androidx.compose.animation.core.Animatable
4 | import androidx.compose.foundation.interaction.MutableInteractionSource
5 | import androidx.compose.foundation.layout.PaddingValues
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.runtime.Stable
8 | import androidx.compose.runtime.getValue
9 | import androidx.compose.runtime.mutableIntStateOf
10 | import androidx.compose.runtime.mutableStateOf
11 | import androidx.compose.runtime.remember
12 | import androidx.compose.runtime.rememberCoroutineScope
13 | import androidx.compose.runtime.setValue
14 | import androidx.compose.ui.geometry.Offset
15 | import androidx.compose.ui.geometry.Rect
16 | import androidx.compose.ui.layout.LayoutCoordinates
17 | import androidx.compose.ui.layout.positionInRoot
18 | import androidx.compose.ui.layout.positionInWindow
19 | import androidx.compose.ui.platform.LocalDensity
20 | import androidx.compose.ui.platform.LocalLayoutDirection
21 | import androidx.compose.ui.unit.Density
22 | import androidx.compose.ui.unit.IntSize
23 | import androidx.compose.ui.unit.LayoutDirection
24 | import androidx.compose.ui.unit.dp
25 | import kotlinx.coroutines.CoroutineScope
26 | import kotlinx.coroutines.launch
27 |
28 | @Composable
29 | fun rememberDraggableMenuState(): DraggableMenuState {
30 | val scope = rememberCoroutineScope()
31 | val density = LocalDensity.current
32 | val layoutDirection = LocalLayoutDirection.current
33 | return remember(scope, density, layoutDirection) {
34 | DraggableMenuState(scope, density, layoutDirection)
35 | }
36 | }
37 |
38 | internal data class HoveredItem(
39 | val index: Int,
40 | val height: Int = 0,
41 | val pointerYToTop: Float = 0f,
42 | ) {
43 | fun isNone() = index < 0
44 |
45 | companion object {
46 | val None = HoveredItem(index = -1)
47 | }
48 | }
49 |
50 | @Stable
51 | class DraggableMenuState(
52 | private val coroutineScope: CoroutineScope,
53 | private val density: Density,
54 | private val layoutDirection: LayoutDirection,
55 | internal var onItemSelected: ((index: Int) -> Unit)? = null,
56 | ) {
57 | private var itemProvider = DraggableMenuItemProvider()
58 |
59 | private var itemCount: Int = 0
60 |
61 | var isMenuShowing by mutableStateOf(false)
62 | private set
63 |
64 | private var pointerPosition by mutableStateOf(Offset.Zero)
65 |
66 | private var pointerOffsetToMenuTopLeft = Offset.Zero
67 |
68 | internal val hoverBarHeight = Animatable(0f)
69 | internal val hoverBarOffset = Animatable(0f)
70 |
71 | internal var hoveredItem by mutableStateOf(HoveredItem.None)
72 | private set
73 | val hoveredItemIndex: Int get() = hoveredItem.index
74 |
75 | private var snappedItemIndex by mutableIntStateOf(-1)
76 | private var snappedOffsetY = 0f
77 |
78 | internal var containerCoordinates: LayoutCoordinates? by mutableStateOf(null)
79 |
80 | internal var anchorCoordinates: LayoutCoordinates? by mutableStateOf(null)
81 |
82 | internal var menuCoordinates: LayoutCoordinates? by mutableStateOf(null)
83 | internal var menuSize = IntSize.Zero
84 | internal var menuPosOnScreen = Offset.Unspecified
85 | internal var menuContentPadding = PaddingValues(0.dp)
86 |
87 | internal var isHovered by mutableStateOf(false)
88 | private set
89 |
90 | internal var isStretchingUp by mutableStateOf(false)
91 | private set
92 |
93 | internal var isStretchingDown by mutableStateOf(false)
94 | private set
95 |
96 | private val itemCoordinatesMap = mutableMapOf()
97 |
98 | private val itemInteractionSources = mutableMapOf()
99 |
100 | internal fun setItemProvider(itemProvider: DraggableMenuItemProvider) {
101 | this.itemProvider = itemProvider
102 | this.itemCount = itemProvider.itemsContents.size
103 | }
104 |
105 | fun showMenu() {
106 | this.isMenuShowing = true
107 | }
108 |
109 | fun hideMenu(hoveredItemIndex: Int = hoveredItem.index) {
110 | isMenuShowing = false
111 | hoveredItem = HoveredItem.None
112 | snappedItemIndex = -1
113 | isHovered = false
114 | isStretchingUp = false
115 | isStretchingDown = false
116 | itemCoordinatesMap.clear()
117 | itemInteractionSources.clear()
118 | if (hoveredItemIndex != -1) {
119 | onItemSelected?.invoke(hoveredItemIndex)
120 | }
121 | }
122 |
123 | internal fun updateItemCoordinates(index: Int, coordinates: LayoutCoordinates) {
124 | itemCoordinatesMap[index] = coordinates
125 | }
126 |
127 | internal fun updateItemInteractionSource(index: Int, source: MutableInteractionSource) {
128 | itemInteractionSources[index] = source
129 | }
130 |
131 | internal fun getItemInteractionSource(index: Int): MutableInteractionSource? {
132 | return itemInteractionSources[index]
133 | }
134 |
135 | internal fun updatePointerPosition(positionOnScreen: Offset) {
136 | if (!isMenuShowing) return
137 |
138 | if (!positionOnScreen.isSpecifiedAndValid()) return
139 | pointerPosition = positionOnScreen
140 |
141 | if (!menuPosOnScreen.isSpecifiedAndValid()) return
142 | pointerOffsetToMenuTopLeft = positionOnScreen - menuPosOnScreen
143 |
144 | val targetItem = calcHoveredItemUsingPosInWindow(positionOnScreen)
145 |
146 | this.hoveredItem = targetItem
147 |
148 | if (!targetItem.isNone()) {
149 | isHovered = true
150 | }
151 |
152 | if (hoverBarHeight.targetValue.toInt() != targetItem.height) {
153 | coroutineScope.launch {
154 | hoverBarHeight.stop()
155 | if (hoverBarHeight.value == 0f) {
156 | hoverBarHeight.snapTo(targetItem.height.toFloat())
157 | } else {
158 | hoverBarHeight.animateTo(targetItem.height.toFloat())
159 | }
160 | }
161 | }
162 |
163 | if (snappedItemIndex != targetItem.index) {
164 | snappedItemIndex = targetItem.index
165 | coroutineScope.launch {
166 | animateHoverBarToSnappedItem()
167 | if (targetItem.index == hoveredItem.index) {
168 | snappedOffsetY =
169 | pointerPosition.y - menuPosOnScreen.y - hoverBarOffset.value
170 | }
171 | }
172 | } else if (!hoverBarOffset.isRunning) {
173 | coroutineScope.launch {
174 | moveHoverBarTo(pointerOffsetToMenuTopLeft.y - snappedOffsetY)
175 | }
176 | }
177 |
178 | if (isHovered) {
179 | isStretchingUp = targetItem.index == 0 &&
180 | targetItem.pointerYToTop < 0f
181 | isStretchingDown = targetItem.index == itemCount - 1 &&
182 | targetItem.pointerYToTop - targetItem.height > 0f
183 | if (isStretchingUp) {
184 | snappedOffsetY = 0f
185 | }
186 | if (isStretchingDown) {
187 | snappedOffsetY = targetItem.height.toFloat()
188 | }
189 | }
190 | }
191 |
192 | private fun calcHoveredItemUsingPosInWindow(position: Offset): HoveredItem {
193 | val menuOffset = this.menuPosOnScreen
194 | if (!menuOffset.isSpecifiedAndValid()) return HoveredItem.None
195 |
196 | if (position.x < menuOffset.x ||
197 | (position.y < menuOffset.y && !isHovered) ||
198 | position.x > menuOffset.x + menuSize.width
199 | ) {
200 | return HoveredItem.None
201 | }
202 |
203 | return calcHoveredItem(position - menuOffset)
204 | }
205 |
206 | internal fun calcHoveredItemUsingPosInMenu(position: Offset): HoveredItem {
207 | return calcHoveredItem(position)
208 | }
209 |
210 | private fun calcHoveredItem(positionToMenuTopLeft: Offset): HoveredItem {
211 | if (itemCount == 0) return HoveredItem.None
212 | try {
213 | val firstItemCoordinates = itemCoordinatesMap[0]
214 | if (firstItemCoordinates != null && isHovered) {
215 | val itemPos = firstItemCoordinates.positionInRoot()
216 | if (positionToMenuTopLeft.x >= itemPos.x &&
217 | positionToMenuTopLeft.x <= itemPos.x + firstItemCoordinates.size.width &&
218 | positionToMenuTopLeft.y < itemPos.y
219 | ) {
220 | return HoveredItem(
221 | index = 0,
222 | height = firstItemCoordinates.size.height,
223 | pointerYToTop = positionToMenuTopLeft.y,
224 | )
225 | }
226 | }
227 |
228 | val lastItemCoordinates = itemCoordinatesMap[itemCount - 1]
229 | if (lastItemCoordinates != null && isHovered) {
230 | val itemPos = lastItemCoordinates.positionInRoot()
231 | if (positionToMenuTopLeft.x >= itemPos.x &&
232 | positionToMenuTopLeft.x <= itemPos.x + lastItemCoordinates.size.width &&
233 | positionToMenuTopLeft.y >= itemPos.y + lastItemCoordinates.size.height
234 | ) {
235 | return HoveredItem(
236 | index = itemCount - 1,
237 | height = lastItemCoordinates.size.height,
238 | pointerYToTop = positionToMenuTopLeft.y - itemPos.y,
239 | )
240 | }
241 | }
242 |
243 | val menuCoordinates = this.menuCoordinates ?: return HoveredItem.None
244 | if (positionToMenuTopLeft.y > menuCoordinates.size.height) {
245 | return HoveredItem.None
246 | }
247 |
248 | for (index in 0 until itemCount) {
249 | val itemCoordinates = itemCoordinatesMap[index] ?: continue
250 | if (!itemCoordinates.isAttached) continue
251 | val itemPos = itemCoordinates.positionInRoot()
252 | if (positionToMenuTopLeft.x >= itemPos.x &&
253 | positionToMenuTopLeft.y >= itemPos.y &&
254 | positionToMenuTopLeft.x <= itemPos.x + itemCoordinates.size.width &&
255 | positionToMenuTopLeft.y <= itemPos.y + itemCoordinates.size.height
256 | ) {
257 | return HoveredItem(
258 | index = index,
259 | height = itemCoordinates.size.height,
260 | pointerYToTop = positionToMenuTopLeft.y - itemPos.y,
261 | )
262 | }
263 | }
264 | } catch (e: IllegalArgumentException) {
265 | e.printStackTrace()
266 | }
267 | return HoveredItem.None
268 | }
269 |
270 | private suspend fun animateHoverBarToSnappedItem() {
271 | val itemCoordinates = itemCoordinatesMap[snappedItemIndex] ?: return
272 | try {
273 | val offset = itemCoordinates.positionInRoot()
274 | hoverBarOffset.stop()
275 | hoverBarOffset.animateTo(offset.y)
276 | } catch (_: IllegalArgumentException) {
277 | }
278 | }
279 |
280 | private suspend fun moveHoverBarTo(y: Float) {
281 | val menuContentBounds = getMenuContentBoundsInRoot() ?: return
282 | try {
283 | val targetY = y.coerceIn(
284 | menuContentBounds.top,
285 | menuContentBounds.bottom - hoverBarHeight.value,
286 | )
287 | hoverBarOffset.snapTo(targetY)
288 | } catch (e: IllegalArgumentException) {
289 | e.printStackTrace()
290 | }
291 | }
292 |
293 | private fun getMenuContentBoundsInRoot(): Rect? {
294 | val menuCoordinates = this.menuCoordinates ?: return null
295 | if (!menuCoordinates.isAttached) return null
296 | val pos = menuCoordinates.positionInRoot()
297 | val size = menuCoordinates.size
298 | return with(density) {
299 | val paddingLeft = menuContentPadding.calculateLeftPadding(layoutDirection).toPx()
300 | val paddingTop = menuContentPadding.calculateTopPadding().toPx()
301 | val paddingRight = menuContentPadding.calculateRightPadding(layoutDirection).toPx()
302 | val paddingBottom = menuContentPadding.calculateBottomPadding().toPx()
303 |
304 | Rect(
305 | left = pos.x + paddingLeft,
306 | top = pos.y + paddingTop,
307 | right = pos.x + size.width - paddingRight,
308 | bottom = pos.y + size.height - paddingBottom,
309 | )
310 | }
311 | }
312 |
313 | internal fun isOutOfMenuBounds(positionOnScreen: Offset): Boolean {
314 | val anchorCoordinates = anchorCoordinates ?: return true
315 | val anchorSize = anchorCoordinates.size
316 | val anchorPos = anchorCoordinates.positionInWindow()
317 | if (positionOnScreen.x >= anchorPos.x &&
318 | positionOnScreen.y >= anchorPos.y &&
319 | positionOnScreen.x <= anchorPos.x + anchorSize.width &&
320 | positionOnScreen.y <= anchorPos.y + anchorSize.height
321 | ) {
322 | // Inside the anchor
323 | return false
324 | }
325 |
326 |
327 | if (positionOnScreen.y + menuSize.height >= anchorPos.y) {
328 | val v1 = Offset(
329 | anchorPos.x + anchorSize.width / 2,
330 | anchorPos.y + anchorSize.height / 2,
331 | )
332 | val v2 = Offset(
333 | menuPosOnScreen.x,
334 | menuPosOnScreen.y
335 | )
336 | val v3 = Offset(
337 | menuPosOnScreen.x + menuSize.width,
338 | menuPosOnScreen.y
339 | )
340 | if (isPointInsideTriangle(positionOnScreen, v1, v2, v3)) {
341 | // Inside the top triangle gap
342 | return false
343 | }
344 | }
345 |
346 | if (positionOnScreen.y <= anchorPos.y + anchorSize.height) {
347 | val v1 = Offset(
348 | anchorPos.x + anchorSize.width / 2,
349 | anchorPos.y + anchorSize.height / 2,
350 | )
351 | val v2 = Offset(
352 | menuPosOnScreen.x,
353 | menuPosOnScreen.y + menuSize.height
354 | )
355 | val v3 = Offset(
356 | menuPosOnScreen.x + menuSize.width,
357 | menuPosOnScreen.y + menuSize.height
358 | )
359 | if (isPointInsideTriangle(positionOnScreen, v1, v2, v3)) {
360 | // Inside the bottom triangle gap
361 | return false
362 | }
363 | }
364 |
365 | return positionOnScreen.x < menuPosOnScreen.x ||
366 | (positionOnScreen.y < menuPosOnScreen.y && !isHovered) ||
367 | positionOnScreen.x > menuPosOnScreen.x + menuSize.width ||
368 | (positionOnScreen.y > menuPosOnScreen.y + menuSize.height && !isHovered)
369 | }
370 |
371 | // Source: https://stackoverflow.com/a/2049593
372 | private fun isPointInsideTriangle(pt: Offset, v1: Offset, v2: Offset, v3: Offset): Boolean {
373 | val d1 = sign(pt, v1, v2)
374 | val d2 = sign(pt, v2, v3)
375 | val d3 = sign(pt, v3, v1)
376 |
377 | val hasNeg = d1 < 0 || d2 < 0 || d3 < 0
378 | val hasPos = d1 > 0 || d2 > 0 || d3 > 0
379 |
380 | return !(hasNeg && hasPos)
381 | }
382 |
383 | private fun sign(p1: Offset, p2: Offset, p3: Offset): Float {
384 | return (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y)
385 | }
386 | }
387 |
--------------------------------------------------------------------------------
/draggablemenu/src/main/java/com/dokar/draggablemenu/Modifiers.kt:
--------------------------------------------------------------------------------
1 | package com.dokar.draggablemenu
2 |
3 | import androidx.compose.animation.core.Animatable
4 | import androidx.compose.animation.core.MutableTransitionState
5 | import androidx.compose.animation.core.Spring
6 | import androidx.compose.animation.core.animateFloat
7 | import androidx.compose.animation.core.spring
8 | import androidx.compose.animation.core.updateTransition
9 | import androidx.compose.foundation.gestures.awaitEachGesture
10 | import androidx.compose.foundation.gestures.awaitFirstDown
11 | import androidx.compose.foundation.gestures.waitForUpOrCancellation
12 | import androidx.compose.foundation.indication
13 | import androidx.compose.foundation.interaction.MutableInteractionSource
14 | import androidx.compose.foundation.interaction.PressInteraction
15 | import androidx.compose.runtime.LaunchedEffect
16 | import androidx.compose.runtime.getValue
17 | import androidx.compose.runtime.remember
18 | import androidx.compose.runtime.rememberCoroutineScope
19 | import androidx.compose.runtime.snapshotFlow
20 | import androidx.compose.ui.Modifier
21 | import androidx.compose.ui.composed
22 | import androidx.compose.ui.geometry.Offset
23 | import androidx.compose.ui.geometry.isUnspecified
24 | import androidx.compose.ui.graphics.TransformOrigin
25 | import androidx.compose.ui.graphics.graphicsLayer
26 | import androidx.compose.ui.input.pointer.PointerEventPass
27 | import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
28 | import androidx.compose.ui.input.pointer.PointerInputChange
29 | import androidx.compose.ui.input.pointer.changedToUp
30 | import androidx.compose.ui.input.pointer.pointerInput
31 | import androidx.compose.ui.layout.onGloballyPositioned
32 | import androidx.compose.ui.layout.positionInWindow
33 | import kotlinx.coroutines.coroutineScope
34 | import kotlinx.coroutines.flow.distinctUntilChanged
35 | import kotlinx.coroutines.flow.filter
36 | import kotlinx.coroutines.flow.map
37 | import kotlinx.coroutines.isActive
38 | import kotlinx.coroutines.launch
39 |
40 | fun Modifier.draggableMenuContainer(state: DraggableMenuState): Modifier {
41 | return onGloballyPositioned { state.containerCoordinates = it }
42 | .pointerInput(state) {
43 | coroutineScope {
44 | awaitEachGesture {
45 | awaitFirstDown()
46 | while (isActive) {
47 | val event = awaitPointerEvent(pass = PointerEventPass.Initial)
48 | val change = event.changes.firstOrNull() ?: continue
49 | if (!state.isMenuShowing) {
50 | continue
51 | }
52 | if (!change.position.isSpecifiedAndValid()) {
53 | continue
54 | }
55 | val offset = state.containerCoordinates?.positionInWindow()
56 | ?.let { if (it.isSpecifiedAndValid()) it else Offset.Zero }
57 | ?: Offset.Zero
58 | val position = change.position + offset
59 | state.updatePointerPosition(position)
60 | if (state.isOutOfMenuBounds(position) ||
61 | event.changes.all { it.changedToUp() }
62 | ) {
63 | state.hideMenu()
64 | }
65 | // Consume all changes
66 | event.changes.forEach { it.consume() }
67 | }
68 | }
69 | }
70 | }
71 | }
72 |
73 | fun Modifier.draggableMenuAnchor(
74 | state: DraggableMenuState,
75 | onClick: (() -> Unit)? = { state.showMenu() },
76 | ): Modifier = composed {
77 | val scope = rememberCoroutineScope()
78 |
79 | val scale = remember { Animatable(1f) }
80 |
81 | val interactionSource = remember { MutableInteractionSource() }
82 |
83 | val indication = rememberScaleIndication()
84 |
85 | fun shouldHideAnchor(
86 | isStretchingDown: Boolean,
87 | isStretchingUp: Boolean,
88 | ): Boolean {
89 | val menuCoordinates = state.menuCoordinates ?: return false
90 | val anchorCoordinates = state.anchorCoordinates ?: return false
91 |
92 | val menuPos = state.menuPosOnScreen
93 | if (!menuPos.isSpecifiedAndValid()) return false
94 |
95 | val anchorPos = anchorCoordinates.positionInWindow()
96 |
97 | val isShowAboveAnchor = menuPos.y + menuCoordinates.size.height <= anchorPos.y
98 | if (isShowAboveAnchor && isStretchingDown && calcStretchDownFactor(state) > 0.1f) {
99 | return true
100 | }
101 |
102 | val isShowBelowAnchor = menuPos.y >= anchorPos.y + anchorCoordinates.size.height
103 | if (isShowBelowAnchor && isStretchingUp && calcStretchUpFactor(state) > 0.1f) {
104 | return true
105 | }
106 |
107 | return false
108 | }
109 |
110 | LaunchedEffect(state) {
111 | snapshotFlow { Triple(state.isStretchingDown, state.isStretchingUp, state.hoveredItem) }
112 | .filter { it.first || it.second || it.third.isNone() }
113 | .map { shouldHideAnchor(it.first, it.second) }
114 | .distinctUntilChanged()
115 | .collect { shouldHideAnchor ->
116 | launch {
117 | scale.stop()
118 | scale.animateTo(
119 | targetValue = if (shouldHideAnchor) 0f else 1f,
120 | animationSpec = spring(
121 | dampingRatio = Spring.DampingRatioLowBouncy,
122 | stiffness = Spring.StiffnessMediumLow,
123 | ),
124 | )
125 | }
126 | }
127 | }
128 |
129 | onGloballyPositioned { state.anchorCoordinates = it }
130 | .indication(interactionSource, indication)
131 | .graphicsLayer {
132 | scaleX = scale.value
133 | scaleY = scale.value
134 | transformOrigin = TransformOrigin.Center
135 | }
136 | .pointerInput(state) {
137 | coroutineScope {
138 | awaitPointerEventScope {
139 | while (isActive) {
140 | val down = awaitFirstDown()
141 | val press = PressInteraction.Press(down.position)
142 | scope.launch { interactionSource.emit(press) }
143 | try {
144 | withTimeout(timeMillis = viewConfiguration.longPressTimeoutMillis) {
145 | waitForUpOrCancellation()
146 | }
147 | } catch (e: PointerEventTimeoutCancellationException) {
148 | state.showMenu()
149 | }
150 | scope.launch { interactionSource.emit(PressInteraction.Release(press)) }
151 | onClick?.invoke()
152 | }
153 | }
154 | }
155 | }
156 | }
157 |
158 | internal fun Modifier.handleMenuTapAndDragGestures(state: DraggableMenuState): Modifier {
159 | fun onPointerPositionChanged(position: Offset): Boolean {
160 | val menuPos = state.menuPosOnScreen
161 | if (menuPos.isSpecifiedAndValid()) {
162 | val posOnScreen = position + menuPos
163 | if (state.isOutOfMenuBounds(posOnScreen)) {
164 | state.hideMenu()
165 | return true
166 | } else {
167 | state.updatePointerPosition(posOnScreen)
168 | }
169 | }
170 | return false
171 | }
172 |
173 | return this.pointerInput(state) {
174 | coroutineScope {
175 | awaitEachGesture {
176 | val down = awaitFirstDown()
177 |
178 | val downItem = state.calcHoveredItemUsingPosInMenu(down.position)
179 | val press = PressInteraction.Press(down.position)
180 | val interactionSource = state.getItemInteractionSource(downItem.index)
181 | if (interactionSource != null) {
182 | launch { interactionSource.emit(press) }
183 | }
184 |
185 | var isTap = false
186 | var change: PointerInputChange? = null
187 |
188 | try {
189 | change = withTimeout(
190 | timeMillis = viewConfiguration.longPressTimeoutMillis
191 | ) {
192 | waitForUpOrCancellation()
193 | }
194 | // Tap
195 | val pos = change?.position ?: down.position
196 | val item = state.calcHoveredItemUsingPosInMenu(pos)
197 | if (!item.isNone()) {
198 | state.hideMenu(hoveredItemIndex = item.index)
199 | }
200 | isTap = true
201 | } catch (_: PointerEventTimeoutCancellationException) {
202 | // Long pressed
203 | val pos = change?.position ?: down.position
204 | onPointerPositionChanged(pos)
205 | }
206 |
207 | if (interactionSource != null) {
208 | launch { interactionSource.emit(PressInteraction.Release(press)) }
209 | }
210 |
211 | if (isTap) {
212 | return@awaitEachGesture
213 | }
214 |
215 | while (isActive) {
216 | val event = awaitPointerEvent(pass = PointerEventPass.Main)
217 |
218 | if (event.changes.all { it.changedToUp() }) {
219 | // Up
220 | state.hideMenu()
221 | break
222 | }
223 |
224 | if (onPointerPositionChanged(event.changes.first().position)) {
225 | break
226 | }
227 | }
228 | }
229 | }
230 | }
231 | }
232 |
233 | internal fun Modifier.animateMenuEnterExit(
234 | menuState: DraggableMenuState,
235 | visibleState: MutableTransitionState,
236 | ): Modifier = composed {
237 | val transition = updateTransition(
238 | transitionState = visibleState,
239 | label = "DraggableMenuTransition",
240 | )
241 |
242 | val transitionValue by transition.animateFloat(
243 | transitionSpec = {
244 | if (false isTransitioningTo true) {
245 | spring(
246 | dampingRatio = Spring.DampingRatioLowBouncy - 0.05f,
247 | stiffness = Spring.StiffnessMediumLow - 50f,
248 | )
249 | } else {
250 | spring(
251 | dampingRatio = Spring.DampingRatioLowBouncy,
252 | stiffness = Spring.StiffnessMediumLow,
253 | )
254 | }
255 | },
256 | label = "transitionValue",
257 | ) { visible ->
258 | if (visible) 1f else 0f
259 | }
260 |
261 | graphicsLayer {
262 | val menuCoordinates = menuState.menuCoordinates
263 | val menuPos = menuState.menuPosOnScreen
264 | val anchorCoordinates = menuState.anchorCoordinates
265 |
266 | if (menuCoordinates == null ||
267 | !menuCoordinates.isAttached ||
268 | menuPos.isUnspecified ||
269 | anchorCoordinates == null ||
270 | !anchorCoordinates.isAttached
271 | ) {
272 | return@graphicsLayer
273 | }
274 |
275 | val menuPosition = menuPos + menuCoordinates.positionInWindow()
276 | val menuSize = menuCoordinates.size
277 | if (menuSize.width == 0 || menuSize.height == 0) {
278 | return@graphicsLayer
279 | }
280 |
281 | val anchorPosition = anchorCoordinates.positionInWindow()
282 | val anchorSize = anchorCoordinates.size
283 | val anchorCenter = Offset(
284 | x = anchorPosition.x + anchorSize.width / 2,
285 | y = anchorPosition.y + anchorSize.height / 2
286 | )
287 |
288 | val pivotX = (anchorCenter.x - menuPosition.x) / menuSize.width
289 | val pivotY = (anchorCenter.y - menuPosition.y) / menuSize.height
290 | transformOrigin = TransformOrigin(
291 | pivotX.coerceIn(0f, 1f),
292 | pivotY.coerceIn(0f, 1f)
293 | )
294 | scaleX = transitionValue
295 | scaleY = transitionValue
296 |
297 | alpha = transitionValue.coerceIn(0f, 1f)
298 | }
299 | }
300 |
301 | internal fun Modifier.stretchEffect(state: DraggableMenuState): Modifier {
302 | return graphicsLayer {
303 | if (!state.isHovered) {
304 | return@graphicsLayer
305 | }
306 | val hoveredItem = state.hoveredItem
307 | if (hoveredItem.isNone()) {
308 | return@graphicsLayer
309 | }
310 | if (state.isStretchingUp) {
311 | transformOrigin = TransformOrigin(0.5f, 1f)
312 | val stretch = calcStretchUpFactor(state)
313 | scaleX = 1f - 0.05f * stretch
314 | scaleY = 1f + 0.05f * stretch
315 | } else if (state.isStretchingDown) {
316 | transformOrigin = TransformOrigin(0.5f, 0f)
317 | val stretch = calcStretchDownFactor(state)
318 | scaleX = 1f - 0.05f * stretch
319 | scaleY = 1f + 0.05f * stretch
320 | }
321 | }
322 | }
323 |
324 | private fun calcStretchUpFactor(state: DraggableMenuState): Float {
325 | val hoveredItem = state.hoveredItem
326 | if (hoveredItem.pointerYToTop >= 0f) {
327 | return 0f
328 | }
329 | return (-hoveredItem.pointerYToTop / state.menuSize.height).coerceAtMost(1f)
330 | }
331 |
332 | private fun calcStretchDownFactor(state: DraggableMenuState): Float {
333 | val hoveredItem = state.hoveredItem
334 | val pointerYToBottom = hoveredItem.pointerYToTop - hoveredItem.height
335 | if (pointerYToBottom <= 0f) {
336 | return 0f
337 | }
338 | return (pointerYToBottom / state.menuSize.height).coerceAtMost(1f)
339 | }
--------------------------------------------------------------------------------
/draggablemenu/src/main/java/com/dokar/draggablemenu/Offset.ext.kt:
--------------------------------------------------------------------------------
1 | package com.dokar.draggablemenu
2 |
3 | import androidx.compose.ui.geometry.Offset
4 | import androidx.compose.ui.geometry.isSpecified
5 |
6 | fun Offset.isSpecifiedAndValid(): Boolean {
7 | return isSpecified && isValid()
8 | }
--------------------------------------------------------------------------------
/draggablemenu/src/main/java/com/dokar/draggablemenu/ScaleIndication.kt:
--------------------------------------------------------------------------------
1 | package com.dokar.draggablemenu
2 |
3 | import androidx.compose.animation.core.animateFloatAsState
4 | import androidx.compose.foundation.Indication
5 | import androidx.compose.foundation.IndicationInstance
6 | import androidx.compose.foundation.interaction.InteractionSource
7 | import androidx.compose.foundation.interaction.collectIsPressedAsState
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.runtime.getValue
10 | import androidx.compose.runtime.remember
11 | import androidx.compose.ui.graphics.drawscope.ContentDrawScope
12 | import androidx.compose.ui.graphics.drawscope.scale
13 | import androidx.compose.ui.graphics.drawscope.withTransform
14 |
15 | @Composable
16 | internal fun rememberScaleIndication(
17 | pressedScale: Float = 0.85f,
18 | ): ScaleIndication {
19 | return remember(pressedScale) { ScaleIndication(pressedScale) }
20 | }
21 |
22 | internal class ScaleIndication(
23 | private val pressedScale: Float,
24 | ) : Indication {
25 | @Composable
26 | override fun rememberUpdatedInstance(interactionSource: InteractionSource): IndicationInstance {
27 | val isPressed by interactionSource.collectIsPressedAsState()
28 |
29 | val scale = animateFloatAsState(
30 | targetValue = if (isPressed) pressedScale else 1f,
31 | label = "Scale",
32 | )
33 |
34 | return remember(scale) {
35 | ScaleIndicationInstance { scale.value }
36 | }
37 | }
38 | }
39 |
40 | private class ScaleIndicationInstance(
41 | private val scaleProvider: () -> Float,
42 | ) : IndicationInstance {
43 | override fun ContentDrawScope.drawIndication() {
44 | val contentScope = this
45 | withTransform(
46 | transformBlock = {
47 | scale(scaleProvider())
48 | },
49 | drawBlock = {
50 | contentScope.drawContent()
51 | }
52 | )
53 | }
54 | }
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
24 | android.nonFinalResIds=false
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dokar3/draggable-menu/48bfc175cfb5bb23d31e0bea291a731303fd23b0/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Tue Jan 03 09:55:08 CST 2023
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or 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 UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/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 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/images/sample_dark.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dokar3/draggable-menu/48bfc175cfb5bb23d31e0bea291a731303fd23b0/images/sample_dark.gif
--------------------------------------------------------------------------------
/images/sample_press_anchor_to_drag.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dokar3/draggable-menu/48bfc175cfb5bb23d31e0bea291a731303fd23b0/images/sample_press_anchor_to_drag.gif
--------------------------------------------------------------------------------
/images/sample_press_item_to_drag.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dokar3/draggable-menu/48bfc175cfb5bb23d31e0bea291a731303fd23b0/images/sample_press_item_to_drag.gif
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google()
4 | mavenCentral()
5 | gradlePluginPortal()
6 | }
7 | }
8 | dependencyResolutionManagement {
9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
10 | repositories {
11 | google()
12 | mavenCentral()
13 | }
14 | }
15 | rootProject.name = "DraggableMenu"
16 | include ':app'
17 | include ':draggablemenu'
18 |
--------------------------------------------------------------------------------