├── .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 | | ![](images/sample_press_anchor_to_drag.gif) | ![](images/sample_press_item_to_drag.gif) | ![](images/sample_dark.gif) | 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 | --------------------------------------------------------------------------------