diff --git a/FluentUI.Demo/build.gradle b/FluentUI.Demo/build.gradle index e7a867b3f..a3d1fd164 100644 --- a/FluentUI.Demo/build.gradle +++ b/FluentUI.Demo/build.gradle @@ -37,7 +37,7 @@ android { buildTypes { release { - minifyEnabled false + minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } diff --git a/FluentUI.Demo/src/androidTest/java/com/microsoft/fluentuidemo/UiTestSuite.kt b/FluentUI.Demo/src/androidTest/java/com/microsoft/fluentuidemo/UiTestSuite.kt index be25a2980..6e6f61adb 100644 --- a/FluentUI.Demo/src/androidTest/java/com/microsoft/fluentuidemo/UiTestSuite.kt +++ b/FluentUI.Demo/src/androidTest/java/com/microsoft/fluentuidemo/UiTestSuite.kt @@ -1,9 +1,6 @@ package com.microsoft.fluentuidemo -import com.microsoft.fluentuidemo.demos.ActionBarLayoutActivityUITest -import com.microsoft.fluentuidemo.demos.AppBarLayoutActivityUITest -import com.microsoft.fluentuidemo.demos.V2AvatarActivityUITest -import com.microsoft.fluentuidemo.demos.V2AvatarGroupActivityUITest +import com.microsoft.fluentuidemo.demos.* import org.junit.runner.RunWith import org.junit.runners.Suite @@ -12,6 +9,7 @@ import org.junit.runners.Suite ActionBarLayoutActivityUITest::class, AppBarLayoutActivityUITest::class, V2AvatarActivityUITest::class, - V2AvatarGroupActivityUITest::class + V2AvatarGroupActivityUITest::class, + V2DrawerActivityUITest::class ) class UiTestSuite \ No newline at end of file diff --git a/FluentUI.Demo/src/androidTest/java/com/microsoft/fluentuidemo/demos/V2DrawerActivityUITest.kt b/FluentUI.Demo/src/androidTest/java/com/microsoft/fluentuidemo/demos/V2DrawerActivityUITest.kt new file mode 100644 index 000000000..bee2c216c --- /dev/null +++ b/FluentUI.Demo/src/androidTest/java/com/microsoft/fluentuidemo/demos/V2DrawerActivityUITest.kt @@ -0,0 +1,269 @@ +package com.microsoft.fluentuidemo.demos + +import android.content.Intent +import android.content.res.Resources +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.intent.Intents +import androidx.test.platform.app.InstrumentationRegistry +import com.microsoft.fluentuidemo.DemoActivity +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.* + +//Below tag name used in Drawer component +private const val DRAWER_HANDLE_TAG = "Drawer Handle" +private const val DRAWER_CONTENT_TAG = "Drawer Content" +private const val DRAWER_SCRIM_TAG = "Drawer Scrim" + + +class V2DrawerActivityUITest { + private fun launchActivity() { + ActivityScenario.launch(setUpIntentForActivity()) + } + + private fun setUpIntentForActivity(): Intent { + val targetContext = InstrumentationRegistry.getInstrumentation().targetContext + val intent = Intent(targetContext, V2DrawerActivity::class.java) + intent.putExtra(DemoActivity.DEMO_ID, UUID.randomUUID()) + return intent + } + + @get:Rule + val composeTestRule = createComposeRule() + + @Before + fun initialize() { + Intents.init() + launchActivity() + } + + private val drawerHandle = composeTestRule.onNodeWithTag(DRAWER_HANDLE_TAG) + private val drawerContent = composeTestRule.onNodeWithTag(DRAWER_CONTENT_TAG) + private val drawerScrim = composeTestRule.onNodeWithTag(DRAWER_SCRIM_TAG) + + private fun openCheckForVerticalDrawer() { + drawerHandle.assertExists("Drawer Handle not shown") + drawerContent.assertExists("Drawer Content not shown") + drawerScrim.assertExists("Drawer Scrim not shown") + } + + private fun closeCheckForVerticalDrawer() { + drawerHandle.assertDoesNotExist() + drawerScrim.assertDoesNotExist() + drawerContent.assertDoesNotExist() + } + + private fun openCheckForHorizontalDrawer() { + drawerContent.assertExists("Drawer Content not shown") + drawerScrim.assertExists("Drawer Scrim not shown") + } + + private fun closeCheckForHorizontalDrawer() { + drawerScrim.assertDoesNotExist() + drawerContent.assertDoesNotExist() + } + + private fun dpToPx(value: Dp) = (value * Resources + .getSystem() + .displayMetrics.density).value + + @Test + fun testBottomDrawer1() { + composeTestRule.onNodeWithText("Show Bottom Drawer").performClick() + openCheckForVerticalDrawer() + + val scrimEnd = drawerHandle.fetchSemanticsNode().positionInRoot.y.toInt() + + //Click on drawer should not close it. + drawerScrim.performTouchInput { + click(Offset((0..width).random().toFloat(), (scrimEnd..height).random().toFloat())) + } + openCheckForVerticalDrawer() + + //Click on scrim should close it. + drawerScrim.performTouchInput { + click(Offset((0..width).random().toFloat(), (0..scrimEnd).random().toFloat())) + } + closeCheckForVerticalDrawer() + } + + @Test + fun testBottomDrawer2() { + composeTestRule.onNodeWithText("Show Bottom Drawer").performClick() + openCheckForVerticalDrawer() + + //SwipeDown on drawerHandle should close it. + drawerHandle.performTouchInput { + swipeDown( + startY = drawerHandle.fetchSemanticsNode().positionInRoot.y, + endY = drawerScrim.fetchSemanticsNode().size.height.toFloat()) + } + closeCheckForVerticalDrawer() + } + + @Test + fun testBottomDrawer3() { + composeTestRule.onNodeWithText("Show Bottom Drawer").performClick() + openCheckForVerticalDrawer() + + val scrimEnd = drawerHandle.fetchSemanticsNode().positionInRoot.y.toInt() + + //SwipeDown on drawerContent should close it. + drawerContent.performTouchInput { + swipeDown( + startY = (scrimEnd..scrimEnd + height / 2).random().toFloat(), + endY = drawerScrim.fetchSemanticsNode().size.height.toFloat()) + } + closeCheckForVerticalDrawer() + } + + @Test + fun testBottomDrawer4() { + composeTestRule.onNodeWithText("Show Bottom Drawer").performClick() + openCheckForVerticalDrawer() + + val scrimEnd = drawerHandle.fetchSemanticsNode().positionInRoot.y.toInt() + + //SwipeUp on drawer should not close it. Instead it expand the drawer,if possible + drawerContent.performTouchInput { swipeUp(startY = (scrimEnd..height).random().toFloat()) } + openCheckForVerticalDrawer() + + //SwipeDown of drawerHandle to bottom should close it. + drawerHandle.performTouchInput { + swipeDown( + startY = drawerHandle.fetchSemanticsNode().positionInRoot.y, + endY = drawerScrim.fetchSemanticsNode().size.height.toFloat()) + } + closeCheckForVerticalDrawer() + } + + @Test + fun testBottomDrawer5() { + composeTestRule.onNodeWithText("Show Bottom Drawer").performClick() + openCheckForVerticalDrawer() + + //SwipeDown a little should not close the drawer + drawerHandle.performTouchInput { + swipeDown( + startY = drawerHandle.fetchSemanticsNode().positionInRoot.y, + endY = drawerHandle.fetchSemanticsNode().positionInRoot.y + (0..dpToPx(26.dp).toInt()).random()) + } + composeTestRule.waitForIdle() + openCheckForVerticalDrawer() + + } + + @Test + fun testLeftDrawer1() { + composeTestRule.onNodeWithText("Show Left Drawer").performClick() + openCheckForHorizontalDrawer() + + val drawerEnd = drawerContent.fetchSemanticsNode().boundsInRoot.right.toInt() + + //Click on drawer content should not close drawer + drawerScrim.performTouchInput { + click(Offset((0..drawerEnd).random().toFloat(), (0..height).random().toFloat())) + } + openCheckForHorizontalDrawer() + + //Click on scrim should close drawer + drawerScrim.performTouchInput { + click(Offset((drawerEnd..width).random().toFloat(), (0..height).random().toFloat())) + } + closeCheckForHorizontalDrawer() + } + + @Test + fun testLeftDrawer2() { + composeTestRule.onNodeWithText("Show Left Drawer").performClick() + openCheckForHorizontalDrawer() + + //Swipe right should not close the drawer + drawerContent.performTouchInput { swipeRight() } + openCheckForHorizontalDrawer() + + //Swipe left should not close the drawer + drawerContent.performTouchInput { swipeLeft() } + closeCheckForHorizontalDrawer() + } + + @Test + fun testRightDrawer1() { + composeTestRule.onNodeWithText("Show Right Drawer").performClick() + openCheckForHorizontalDrawer() + + val drawerStart = drawerContent.fetchSemanticsNode().boundsInRoot.left.toInt() + //Click on drawer content should not close drawer + drawerScrim.performTouchInput { + click(Offset((drawerStart..width).random().toFloat(), (0..height).random().toFloat())) + } + openCheckForHorizontalDrawer() + + //Click on scrim should close drawer + drawerScrim.performTouchInput { + click(Offset((0..drawerStart).random().toFloat(), (0..height).random().toFloat())) + } + closeCheckForHorizontalDrawer() + } + + @Test + fun testRightDrawer2() { + composeTestRule.onNodeWithText("Show Right Drawer").performClick() + openCheckForHorizontalDrawer() + + //Swipe left should not close the drawer + drawerContent.performTouchInput { swipeLeft() } + openCheckForHorizontalDrawer() + + //Swipe right should close the drawer + drawerContent.performTouchInput { swipeRight() } + closeCheckForHorizontalDrawer() + } + + @Test + fun testTopDrawer1() { + composeTestRule.onNodeWithText("Show Top Drawer").performClick() + openCheckForVerticalDrawer() + //Click on Drawer area + drawerScrim.performTouchInput { + val drawerLength = drawerHandle.fetchSemanticsNode().boundsInRoot.top.toInt() + click(Offset((0..width).random().toFloat(), (0..drawerLength).random().toFloat())) + } + openCheckForVerticalDrawer() + //Click on Scrim area + drawerScrim.performTouchInput { + val scrimStart = drawerHandle.fetchSemanticsNode().boundsInRoot.bottom + dpToPx(8.dp) + click(Offset((0..width).random().toFloat(), (scrimStart.toInt()..height).random().toFloat())) + } + closeCheckForVerticalDrawer() + } + + @Test + fun testTopDrawer2() { + composeTestRule.onNodeWithText("Show Top Drawer").performClick() + openCheckForVerticalDrawer() + + //Swipe up on content should not close top drawer + drawerContent.performTouchInput { swipeUp() } + openCheckForVerticalDrawer() + + //Close Top Drawer by dragging Handle to top. + drawerHandle.performTouchInput { + swipeUp(startY = drawerHandle.fetchSemanticsNode().boundsInRoot.top) + } + closeCheckForVerticalDrawer() + } + + @After + fun tearDown() { + Intents.release() + } + +} \ No newline at end of file diff --git a/FluentUI.Demo/src/main/AndroidManifest.xml b/FluentUI.Demo/src/main/AndroidManifest.xml index 3817c80c8..d526c60d0 100644 --- a/FluentUI.Demo/src/main/AndroidManifest.xml +++ b/FluentUI.Demo/src/main/AndroidManifest.xml @@ -42,6 +42,7 @@ + (R.id.compose_here) + + composeView.setContent { + FluentTheme { + CreateActivityUI() + } + } + } +} + +enum class ContentType { + FULL_PAGE_SCROLLABLE_CONTENT, + EXPANDABLE_SIZE_CONTENT, + WRAPPED_SIZE_CONTENT +} + +@Composable +private fun CreateActivityUI() { + LazyColumn(horizontalAlignment = Alignment.CenterHorizontally) { + item { + CreateDrawerWithButtonOnPrimarySurfaceToInvokeIt( + "Show Bottom Drawer", + BehaviorType.BOTTOM, + getDrawerContent() + ) + } + item { + CreateDrawerWithButtonOnPrimarySurfaceToInvokeIt( + "Show Left Drawer", + BehaviorType.LEFT, + getDrawerContent() + ) + } + item { + CreateDrawerWithButtonOnPrimarySurfaceToInvokeIt( + "Show Right Drawer", + BehaviorType.RIGHT, + getDrawerContent() + ) + } + item { + CreateDrawerWithButtonOnPrimarySurfaceToInvokeIt( + "Show Top Drawer", + BehaviorType.TOP, + getDrawerContent() + ) + } + item { + CreateDrawerWithButtonOnPrimarySurfaceToInvokeIt( + "Show Fixed Drawer", + BehaviorType.BOTTOM, + getDrawerContent(), + expandable = false + ) + } + item { + CreateDrawerWithButtonOnPrimarySurfaceToInvokeIt( + "Show No Fade Drawer", + BehaviorType.BOTTOM, + getDrawerContent(), + expandable = false, + enableScrim = false + ) + } + item { + CreateDrawerWithButtonOnPrimarySurfaceToInvokeIt( + "Show Content Wrapped Expanded Bottom Drawer", + BehaviorType.BOTTOM, + getDrawerContent(contentType = ContentType.EXPANDABLE_SIZE_CONTENT) + ) + } + item { + CreateDrawerWithButtonOnPrimarySurfaceToInvokeIt( + "Show Content Wrapped Bottom Drawer", + BehaviorType.BOTTOM, + getDrawerContent(contentType = ContentType.WRAPPED_SIZE_CONTENT) + ) + } + item { + CreateDrawerWithButtonOnPrimarySurfaceToInvokeIt( + "Show Content Wrapped Top Drawer", + BehaviorType.TOP, + getDrawerContent(contentType = ContentType.WRAPPED_SIZE_CONTENT) + ) + } + item { + CreateDrawerWithButtonOnPrimarySurfaceToInvokeIt( + "Show Bottom Outer Drawer", + BehaviorType.BOTTOM, + getDrawerInDrawerContent() + ) + } + item { + CreateDrawerWithButtonOnPrimarySurfaceToInvokeIt( + "Show Left Outer Drawer", + BehaviorType.LEFT, + getDrawerInDrawerContent() + ) + } + } +} + +@Composable +private fun CreateDrawerWithButtonOnPrimarySurfaceToInvokeIt( + primaryScreenButtonText: String, + behaviorType: BehaviorType, + drawerContent: @Composable ((() -> Unit) -> Unit), + expandable: Boolean = true, + enableScrim: Boolean = true +) { + val scope = rememberCoroutineScope() + val drawerState = rememberDrawerState() + val open: () -> Unit = { + scope.launch { drawerState.open() } + } + val close: () -> Unit = { + scope.launch { drawerState.close() } + } + PrimarySurfaceContent( + open, + text = primaryScreenButtonText + ) + Drawer( + drawerState = drawerState, + drawerContent = { drawerContent(close) }, + behaviorType = behaviorType, + expandable = expandable, + scrimVisible = enableScrim + ) +} + +@Composable +private fun PrimarySurfaceContent( + onClick: () -> Unit, + text: String, + height: Dp = 20.dp, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(Modifier.height(height)) + com.microsoft.fluentui.tokenized.controls.Button( + style = ButtonStyle.Button, + size = ButtonSize.Medium, + text = text, + onClick = onClick + ) + } +} + +@Composable +private fun getDrawerContent( + contentType: ContentType = ContentType.FULL_PAGE_SCROLLABLE_CONTENT +): @Composable ((close: () -> Unit) -> Unit) { + return { _ -> + lateinit var context: Context + AndroidView( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + factory = { + context = it + val view = it.activity!!.layoutInflater.inflate( + R.layout.demo_drawer_content, + null + )!!.rootView + val personaList = createPersonaList(context) + (view as PersonaListView).personas = when (contentType) { + ContentType.FULL_PAGE_SCROLLABLE_CONTENT -> personaList + ContentType.EXPANDABLE_SIZE_CONTENT -> personaList.take(7) as ArrayList + ContentType.WRAPPED_SIZE_CONTENT -> personaList.take(2) as ArrayList + } + view + } + ) {} + } +} + +@Composable +private fun getDrawerInDrawerContent(sideDrawer: Boolean = false): @Composable ((() -> Unit) -> Unit) { + return { close -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = if (sideDrawer) Modifier.width(250.dp) else Modifier + ) { + com.microsoft.fluentui.tokenized.controls.Button( + style = ButtonStyle.Button, + size = ButtonSize.Medium, + text = "Close Drawer", + onClick = close + ) + + val scopeB = rememberCoroutineScope() + val drawerStateB = rememberDrawerState() + + //Button on Outer Drawer Surface + PrimarySurfaceContent( + onClick = { + scopeB.launch { + drawerStateB.open() + } + }, + text = "Show Inner Drawer" + ) + Drawer( + drawerState = drawerStateB, + drawerContent = { + getDrawerContent()() + { + scopeB.launch { + drawerStateB.close() + } + } + + }, + expandable = true + ) + } + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 50cb2303c..ab3120458 100644 --- a/build.gradle +++ b/build.gradle @@ -44,6 +44,7 @@ allprojects { espressoVersion = '3.3.0' roboelectricVersion = '4.4' constraintLayoutVersion = '2.0.4' + constraintLayoutComposeVersion = '1.0.1' exifInterfaceVersion = '1.3.2' duoVersion = '1.0.0-alpha01' tokenautocompleteVersion = '2.0.8' diff --git a/cgmanifest.json b/cgmanifest.json new file mode 100644 index 000000000..a375c32c4 --- /dev/null +++ b/cgmanifest.json @@ -0,0 +1,14 @@ +{ + "Registrations" : [ + { + "Component" : { + "Git" : { + "CommitHash" : "f0fbedec21af08335607371c0efc921e4c22c90b", + "RepositoryUrl" : "https://github.com/microsoft/fluentui-android" + }, + "Type" : "git" + }, + "DevelopmentDependency" : false + } + ] +} \ No newline at end of file diff --git a/fluentui_core/build.gradle b/fluentui_core/build.gradle index 7ff3ab68d..ee5275732 100644 --- a/fluentui_core/build.gradle +++ b/fluentui_core/build.gradle @@ -69,6 +69,7 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation "com.microsoft.device:dualscreen-layout:$duoVersion" implementation "androidx.constraintlayout:constraintlayout:$constraintLayoutVersion" + implementation "androidx.compose.material:material:$composeVersion" testImplementation "junit:junit:$junitVersion" testImplementation "org.robolectric:robolectric:$roboelectricVersion" @@ -79,6 +80,7 @@ dependencies { implementation "androidx.compose.ui:ui-text:$composeVersion" implementation "androidx.compose.ui:ui-graphics:$composeVersion" implementation "androidx.compose.ui:ui-unit:$composeVersion" + implementation "androidx.compose.ui:ui-util:$composeVersion" implementation "androidx.compose.runtime:runtime:$composeVersion" implementation "androidx.compose.foundation:foundation:$composeVersion" implementation "androidx.compose.material:material-icons-core:$composeVersion" diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/compose/Strings.android.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/compose/Strings.android.kt new file mode 100644 index 000000000..1f6851971 --- /dev/null +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/compose/Strings.android.kt @@ -0,0 +1,23 @@ +package com.microsoft.fluentui.compose + +import androidx.compose.runtime.Composable +import androidx.compose.ui.R +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext + +@Composable +fun getString(string: Strings): String { + LocalConfiguration.current + val resources = LocalContext.current.resources + //TODO Add Strings resource in core module & update String names. + return when (string) { + Strings.NavigationMenu -> resources.getString(R.string.navigation_menu) + Strings.CloseDrawer -> resources.getString(R.string.close_drawer) + Strings.CloseSheet -> resources.getString(R.string.close_sheet) + Strings.DefaultErrorMessage -> resources.getString(R.string.default_error_message) + Strings.ExposedDropdownMenu -> resources.getString(R.string.dropdown_menu) + Strings.SliderRangeStart -> resources.getString(R.string.range_start) + Strings.SliderRangeEnd -> resources.getString(R.string.range_end) + else -> "" + } +} \ No newline at end of file diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/compose/Strings.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/compose/Strings.kt new file mode 100644 index 000000000..d250a61f3 --- /dev/null +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/compose/Strings.kt @@ -0,0 +1,18 @@ +package com.microsoft.fluentui.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable + +@Immutable +@kotlin.jvm.JvmInline +value class Strings private constructor(@Suppress("unused") private val value: Int) { + companion object { + val NavigationMenu = Strings(0) + val CloseDrawer = Strings(1) + val CloseSheet = Strings(2) + val DefaultErrorMessage = Strings(3) + val ExposedDropdownMenu = Strings(4) + val SliderRangeStart = Strings(5) + val SliderRangeEnd = Strings(6) + } +} diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/compose/Swipeable.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/compose/Swipeable.kt new file mode 100644 index 000000000..1fab25a50 --- /dev/null +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/compose/Swipeable.kt @@ -0,0 +1,862 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.microsoft.fluentui.compose + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.SpringSpec +import androidx.compose.foundation.gestures.DraggableState +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material.rememberSwipeableState +import androidx.compose.material.swipeable +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import com.microsoft.fluentui.compose.SwipeableDefaults.AnimationSpec +import com.microsoft.fluentui.compose.SwipeableDefaults.StandardResistanceFactor +import com.microsoft.fluentui.compose.SwipeableDefaults.VelocityThreshold +import com.microsoft.fluentui.compose.SwipeableDefaults.resistanceConfig +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.sign +import kotlin.math.sin + +/** + * State of the [swipeable] modifier. + * + * This contains necessary information about any ongoing swipe or animation and provides methods + * to change the state either immediately or by starting an animation. To create and remember a + * [SwipeableState] with the default animation clock, use [rememberSwipeableState]. + * + * @param initialValue The initial value of the state. + * @param animationSpec The default animation that will be used to animate to a new state. + * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change. + */ +open class SwipeableState( + initialValue: T, + internal val animationSpec: AnimationSpec = AnimationSpec, + val confirmStateChange: (newValue: T) -> Boolean = { true } +) { + /** + * The current value of the state. + * + * If no swipe or animation is in progress, this corresponds to the anchor at which the + * [swipeable] is currently settled. If a swipe or animation is in progress, this corresponds + * the last anchor at which the [swipeable] was settled before the swipe or animation started. + */ + var currentValue: T by mutableStateOf(initialValue) + private set + + /** + * Whether the state is currently animating. + */ + var isAnimationRunning: Boolean by mutableStateOf(false) + private set + + /** + * The current position (in pixels) of the [swipeable]. + * + * You should use this state to offset your content accordingly. The recommended way is to + * use `Modifier.offsetPx`. This includes the resistance by default, if resistance is enabled. + */ + val offset: State get() = offsetState + + /** + * The amount by which the [swipeable] has been swiped past its bounds. + */ + val overflow: State get() = overflowState + + // Use `Float.NaN` as a placeholder while the state is uninitialised. + private val offsetState = mutableStateOf(0f) + private val overflowState = mutableStateOf(0f) + + // the source of truth for the "real"(non ui) position + // basically position in bounds + overflow + private val absoluteOffset = mutableStateOf(0f) + + // current animation target, if animating, otherwise null + private val animationTarget = mutableStateOf(null) + + internal var anchors by mutableStateOf(emptyMap()) + + private val latestNonEmptyAnchorsFlow: Flow> = + snapshotFlow { anchors } + .filter { it.isNotEmpty() } + .take(1) + + internal var minBound = Float.NEGATIVE_INFINITY + internal var maxBound = Float.POSITIVE_INFINITY + + internal fun ensureInit(newAnchors: Map) { + if (anchors.isEmpty()) { + // need to do initial synchronization synchronously :( + val initialOffset = newAnchors.getOffset(currentValue) + requireNotNull(initialOffset) { + "The initial value must have an associated anchor." + } + offsetState.value = initialOffset + absoluteOffset.value = initialOffset + } + } + + internal suspend fun processNewAnchors( + oldAnchors: Map, + newAnchors: Map + ) { + if (oldAnchors.isEmpty()) { + // If this is the first time that we receive anchors, then we need to initialise + // the state so we snap to the offset associated to the initial value. + minBound = newAnchors.keys.minOrNull()!! + maxBound = newAnchors.keys.maxOrNull()!! + val initialOffset = newAnchors.getOffset(currentValue) + requireNotNull(initialOffset) { + "The initial value must have an associated anchor." + } + snapInternalToOffset(initialOffset) + } else if (newAnchors != oldAnchors) { + // If we have received new anchors, then the offset of the current value might + // have changed, so we need to animate to the new offset. If the current value + // has been removed from the anchors then we animate to the closest anchor + // instead. Note that this stops any ongoing animation. + minBound = Float.NEGATIVE_INFINITY + maxBound = Float.POSITIVE_INFINITY + val animationTargetValue = animationTarget.value + // if we're in the animation already, let's find it a new home + val targetOffset = if (animationTargetValue != null) { + // first, try to map old state to the new state + val oldState = oldAnchors[animationTargetValue] + val newState = newAnchors.getOffset(oldState) + // return new state if exists, or find the closes one among new anchors + newState ?: newAnchors.keys.minByOrNull { abs(it - animationTargetValue) }!! + } else { + // we're not animating, proceed by finding the new anchors for an old value + val actualOldValue = oldAnchors[offset.value] + val value = if (actualOldValue == currentValue) currentValue else actualOldValue + newAnchors.getOffset(value) ?: newAnchors + .keys.minByOrNull { abs(it - offset.value) }!! + } + try { + animateInternalToOffset(targetOffset, animationSpec) + } catch (c: CancellationException) { + // If the animation was interrupted for any reason, snap as a last resort. + snapInternalToOffset(targetOffset) + } finally { + currentValue = newAnchors.getValue(targetOffset) + minBound = newAnchors.keys.minOrNull()!! + maxBound = newAnchors.keys.maxOrNull()!! + } + } + } + + internal var thresholds: (Float, Float) -> Float by mutableStateOf({ _, _ -> 0f }) + + internal var velocityThreshold by mutableStateOf(0f) + + internal var resistance: ResistanceConfig? by mutableStateOf(null) + + internal val draggableState = DraggableState { + val newAbsolute = absoluteOffset.value + it + val clamped = newAbsolute.coerceIn(minBound, maxBound) + val overflow = newAbsolute - clamped + val resistanceDelta = resistance?.computeResistance(overflow) ?: 0f + offsetState.value = clamped + resistanceDelta + overflowState.value = overflow + absoluteOffset.value = newAbsolute + } + + private suspend fun snapInternalToOffset(target: Float) { + draggableState.drag { + dragBy(target - absoluteOffset.value) + } + } + + private suspend fun animateInternalToOffset(target: Float, spec: AnimationSpec) { + draggableState.drag { + var prevValue = absoluteOffset.value + animationTarget.value = target + isAnimationRunning = true + try { + Animatable(prevValue).animateTo(target, spec) { + dragBy(this.value - prevValue) + prevValue = this.value + } + } finally { + animationTarget.value = null + isAnimationRunning = false + } + } + } + + /** + * The target value of the state. + * + * If a swipe is in progress, this is the value that the [swipeable] would animate to if the + * swipe finished. If an animation is running, this is the target value of that animation. + * Finally, if no swipe or animation is in progress, this is the same as the [currentValue]. + */ + val targetValue: T + get() { + // TODO(calintat): Track current velocity (b/149549482) and use that here. + val target = animationTarget.value ?: computeTarget( + offset = offset.value, + lastValue = anchors.getOffset(currentValue) ?: offset.value, + anchors = anchors.keys, + thresholds = thresholds, + velocity = 0f, + velocityThreshold = Float.POSITIVE_INFINITY + ) + return anchors[target] ?: currentValue + } + + /** + * Information about the ongoing swipe or animation, if any. See [SwipeProgress] for details. + * + * If no swipe or animation is in progress, this returns `SwipeProgress(value, value, 1f)`. + */ + val progress: SwipeProgress + get() { + val bounds = findBounds(offset.value, anchors.keys) + val from: T + val to: T + val fraction: Float + when (bounds.size) { + 0 -> { + from = currentValue + to = currentValue + fraction = 1f + } + 1 -> { + from = anchors.getValue(bounds[0]) + to = anchors.getValue(bounds[0]) + fraction = 1f + } + else -> { + val (a, b) = + if (direction > 0f) { + bounds[0] to bounds[1] + } else { + bounds[1] to bounds[0] + } + from = anchors.getValue(a) + to = anchors.getValue(b) + fraction = (offset.value - a) / (b - a) + } + } + return SwipeProgress(from, to, fraction) + } + + /** + * The direction in which the [swipeable] is moving, relative to the current [currentValue]. + * + * This will be either 1f if it is is moving from left to right or top to bottom, -1f if it is + * moving from right to left or bottom to top, or 0f if no swipe or animation is in progress. + */ + val direction: Float + get() = anchors.getOffset(currentValue)?.let { sign(offset.value - it) } ?: 0f + + /** + * Set the state without any animation and suspend until it's set + * + * @param targetValue The new target value to set [currentValue] to. + */ + suspend fun snapTo(targetValue: T) { + latestNonEmptyAnchorsFlow.collect { anchors -> + val targetOffset = anchors.getOffset(targetValue) + requireNotNull(targetOffset) { + "The target value must have an associated anchor." + } + snapInternalToOffset(targetOffset) + currentValue = targetValue + } + } + + /** + * Set the state to the target value by starting an animation. + * + * @param targetValue The new value to animate to. + * @param anim The animation that will be used to animate to the new value. + */ + suspend fun animateTo(targetValue: T, anim: AnimationSpec = animationSpec) { + latestNonEmptyAnchorsFlow.collect { anchors -> + try { + val targetOffset = anchors.getOffset(targetValue) + requireNotNull(targetOffset) { + "The target value must have an associated anchor." + } + animateInternalToOffset(targetOffset, anim) + } finally { + val endOffset = absoluteOffset.value + val endValue = anchors + // fighting rounding error once again, anchor should be as close as 0.5 pixels + .filterKeys { anchorOffset -> abs(anchorOffset - endOffset) < 0.5f } + .values.firstOrNull() ?: currentValue + currentValue = endValue + } + } + } + + /** + * Perform fling with settling to one of the anchors which is determined by the given + * [velocity]. Fling with settling [swipeable] will always consume all the velocity provided + * since it will settle at the anchor. + * + * In general cases, [swipeable] flings by itself when being swiped. This method is to be + * used for nested scroll logic that wraps the [swipeable]. In nested scroll developer may + * want to trigger settling fling when the child scroll container reaches the bound. + * + * @param velocity velocity to fling and settle with + * + * @return the reason fling ended + */ + suspend fun performFling(velocity: Float) { + latestNonEmptyAnchorsFlow.collect { anchors -> + val lastAnchor = anchors.getOffset(currentValue)!! + val targetValue = computeTarget( + offset = offset.value, + lastValue = lastAnchor, + anchors = anchors.keys, + thresholds = thresholds, + velocity = velocity, + velocityThreshold = velocityThreshold + ) + val targetState = anchors[targetValue] + if (targetState != null && confirmStateChange(targetState)) animateTo(targetState) + // If the user vetoed the state change, rollback to the previous state. + else animateInternalToOffset(lastAnchor, animationSpec) + } + } + + /** + * Force [swipeable] to consume drag delta provided from outside of the regular [swipeable] + * gesture flow. + * + * Note: This method performs generic drag and it won't settle to any particular anchor, * + * leaving swipeable in between anchors. When done dragging, [performFling] must be + * called as well to ensure swipeable will settle at the anchor. + * + * In general cases, [swipeable] drags by itself when being swiped. This method is to be + * used for nested scroll logic that wraps the [swipeable]. In nested scroll developer may + * want to force drag when the child scroll container reaches the bound. + * + * @param delta delta in pixels to drag by + * + * @return the amount of [delta] consumed + */ + fun performDrag(delta: Float): Float { + val potentiallyConsumed = absoluteOffset.value + delta + val clamped = potentiallyConsumed.coerceIn(minBound, maxBound) + val deltaToConsume = clamped - absoluteOffset.value + if (abs(deltaToConsume) > 0) { + draggableState.dispatchRawDelta(deltaToConsume) + } + return deltaToConsume + } + + companion object { + /** + * The default [Saver] implementation for [SwipeableState]. + */ + fun Saver( + animationSpec: AnimationSpec, + confirmStateChange: (T) -> Boolean + ) = Saver, T>( + save = { it.currentValue }, + restore = { SwipeableState(it, animationSpec, confirmStateChange) } + ) + } +} + +/** + * Collects information about the ongoing swipe or animation in [swipeable]. + * + * To access this information, use [SwipeableState.progress]. + * + * @param from The state corresponding to the anchor we are moving away from. + * @param to The state corresponding to the anchor we are moving towards. + * @param fraction The fraction that the current position represents between [from] and [to]. + * Must be between `0` and `1`. + */ +@Immutable +class SwipeProgress( + val from: T, + val to: T, + /*@FloatRange(from = 0.0, to = 1.0)*/ + val fraction: Float +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SwipeProgress<*>) return false + + if (from != other.from) return false + if (to != other.to) return false + if (fraction != other.fraction) return false + + return true + } + + override fun hashCode(): Int { + var result = from?.hashCode() ?: 0 + result = 31 * result + (to?.hashCode() ?: 0) + result = 31 * result + fraction.hashCode() + return result + } + + override fun toString(): String { + return "SwipeProgress(from=$from, to=$to, fraction=$fraction)" + } +} + +/** + * Create and [remember] a [SwipeableState] with the default animation clock. + * + * @param initialValue The initial value of the state. + * @param animationSpec The default animation that will be used to animate to a new state. + * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change. + */ +@Composable +fun rememberSwipeableState( + initialValue: T, + animationSpec: AnimationSpec = AnimationSpec, + confirmStateChange: (newValue: T) -> Boolean = { true } +): SwipeableState { + return rememberSaveable( + saver = SwipeableState.Saver( + animationSpec = animationSpec, + confirmStateChange = confirmStateChange + ) + ) { + SwipeableState( + initialValue = initialValue, + animationSpec = animationSpec, + confirmStateChange = confirmStateChange + ) + } +} + +/** + * Create and [remember] a [SwipeableState] which is kept in sync with another state, i.e.: + * 1. Whenever the [value] changes, the [SwipeableState] will be animated to that new value. + * 2. Whenever the value of the [SwipeableState] changes (e.g. after a swipe), the owner of the + * [value] will be notified to update their state to the new value of the [SwipeableState] by + * invoking [onValueChange]. If the owner does not update their state to the provided value for + * some reason, then the [SwipeableState] will perform a rollback to the previous, correct value. + */ +@Composable +internal fun rememberSwipeableStateFor( + value: T, + onValueChange: (T) -> Unit, + animationSpec: AnimationSpec = AnimationSpec +): SwipeableState { + val swipeableState = remember { + SwipeableState( + initialValue = value, + animationSpec = animationSpec, + confirmStateChange = { true } + ) + } + val forceAnimationCheck = remember { mutableStateOf(false) } + LaunchedEffect(value, forceAnimationCheck.value) { + if (value != swipeableState.currentValue) { + swipeableState.animateTo(value) + } + } + DisposableEffect(swipeableState.currentValue) { + if (value != swipeableState.currentValue) { + onValueChange(swipeableState.currentValue) + forceAnimationCheck.value = !forceAnimationCheck.value + } + onDispose { } + } + return swipeableState +} + +/** + * Enable swipe gestures between a set of predefined states. + * + * To use this, you must provide a map of anchors (in pixels) to states (of type [T]). + * Note that this map cannot be empty and cannot have two anchors mapped to the same state. + * + * When a swipe is detected, the offset of the [SwipeableState] will be updated with the swipe + * delta. You should use this offset to move your content accordingly (see `Modifier.offsetPx`). + * When the swipe ends, the offset will be animated to one of the anchors and when that anchor is + * reached, the value of the [SwipeableState] will also be updated to the state corresponding to + * the new anchor. The target anchor is calculated based on the provided positional [thresholds]. + * + * Swiping is constrained between the minimum and maximum anchors. If the user attempts to swipe + * past these bounds, a resistance effect will be applied by default. The amount of resistance at + * each edge is specified by the [resistance] config. To disable all resistance, set it to `null`. + * + * For an example of a [swipeable] with three states, see: + * + * + * @param T The type of the state. + * @param state The state of the [swipeable]. + * @param anchors Pairs of anchors and states, used to map anchors to states and vice versa. + * @param thresholds Specifies where the thresholds between the states are. The thresholds will be + * used to determine which state to animate to when swiping stops. This is represented as a lambda + * that takes two states and returns the threshold between them in the form of a [ThresholdConfig]. + * Note that the order of the states corresponds to the swipe direction. + * @param orientation The orientation in which the [swipeable] can be swiped. + * @param enabled Whether this [swipeable] is enabled and should react to the user's input. + * @param reverseDirection Whether to reverse the direction of the swipe, so a top to bottom + * swipe will behave like bottom to top, and a left to right swipe will behave like right to left. + * @param interactionSource Optional [MutableInteractionSource] that will passed on to + * the internal [Modifier.draggable]. + * @param resistance Controls how much resistance will be applied when swiping past the bounds. + * @param velocityThreshold The threshold (in dp per second) that the end velocity has to exceed + * in order to animate to the next state, even if the positional [thresholds] have not been reached. + */ +fun Modifier.swipeable( + state: SwipeableState, + anchors: Map, + orientation: Orientation, + enabled: Boolean = true, + reverseDirection: Boolean = false, + interactionSource: MutableInteractionSource? = null, + thresholds: (from: T, to: T) -> ThresholdConfig = { _, _ -> FixedThreshold(56.dp) }, + resistance: ResistanceConfig? = resistanceConfig(anchors.keys), + velocityThreshold: Dp = VelocityThreshold +) = composed( + inspectorInfo = debugInspectorInfo { + name = "swipeable" + properties["state"] = state + properties["anchors"] = anchors + properties["orientation"] = orientation + properties["enabled"] = enabled + properties["reverseDirection"] = reverseDirection + properties["interactionSource"] = interactionSource + properties["thresholds"] = thresholds + properties["resistance"] = resistance + properties["velocityThreshold"] = velocityThreshold + } +) { + require(anchors.isNotEmpty()) { + "You must have at least one anchor." + } + require(anchors.values.distinct().count() == anchors.size) { + "You cannot have two anchors mapped to the same state." + } + val density = LocalDensity.current + state.ensureInit(anchors) + LaunchedEffect(anchors, state) { + val oldAnchors = state.anchors + state.anchors = anchors + state.resistance = resistance + state.thresholds = { a, b -> + val from = anchors.getValue(a) + val to = anchors.getValue(b) + with(thresholds(from, to)) { density.computeThreshold(a, b) } + } + with(density) { + state.velocityThreshold = velocityThreshold.toPx() + } + state.processNewAnchors(oldAnchors, anchors) + } + + Modifier.draggable( + orientation = orientation, + enabled = enabled, + reverseDirection = reverseDirection, + interactionSource = interactionSource, + startDragImmediately = state.isAnimationRunning, + onDragStopped = { velocity -> launch { state.performFling(velocity) } }, + state = state.draggableState + ) +} + +/** + * Interface to compute a threshold between two anchors/states in a [swipeable]. + * + * To define a [ThresholdConfig], consider using [FixedThreshold] and [FractionalThreshold]. + */ +@Stable +interface ThresholdConfig { + /** + * Compute the value of the threshold (in pixels), once the values of the anchors are known. + */ + fun Density.computeThreshold(fromValue: Float, toValue: Float): Float +} + +/** + * A fixed threshold will be at an [offset] away from the first anchor. + * + * @param offset The offset (in dp) that the threshold will be at. + */ +@Immutable +data class FixedThreshold(private val offset: Dp) : ThresholdConfig { + override fun Density.computeThreshold(fromValue: Float, toValue: Float): Float { + return fromValue + offset.toPx() * sign(toValue - fromValue) + } +} + +/** + * A fractional threshold will be at a [fraction] of the way between the two anchors. + * + * @param fraction The fraction (between 0 and 1) that the threshold will be at. + */ +@Immutable +data class FractionalThreshold( + /*@FloatRange(from = 0.0, to = 1.0)*/ + private val fraction: Float +) : ThresholdConfig { + override fun Density.computeThreshold(fromValue: Float, toValue: Float): Float { + return lerp(fromValue, toValue, fraction) + } +} + +/** + * Specifies how resistance is calculated in [swipeable]. + * + * There are two things needed to calculate resistance: the resistance basis determines how much + * overflow will be consumed to achieve maximum resistance, and the resistance factor determines + * the amount of resistance (the larger the resistance factor, the stronger the resistance). + * + * The resistance basis is usually either the size of the component which [swipeable] is applied + * to, or the distance between the minimum and maximum anchors. For a constructor in which the + * resistance basis defaults to the latter, consider using [resistanceConfig]. + * + * You may specify different resistance factors for each bound. Consider using one of the default + * resistance factors in [SwipeableDefaults]: `StandardResistanceFactor` to convey that the user + * has run out of things to see, and `StiffResistanceFactor` to convey that the user cannot swipe + * this right now. Also, you can set either factor to 0 to disable resistance at that bound. + * + * @param basis Specifies the maximum amount of overflow that will be consumed. Must be positive. + * @param factorAtMin The factor by which to scale the resistance at the minimum bound. + * Must not be negative. + * @param factorAtMax The factor by which to scale the resistance at the maximum bound. + * Must not be negative. + */ +@Immutable +class ResistanceConfig( + /*@FloatRange(from = 0.0, fromInclusive = false)*/ + val basis: Float, + /*@FloatRange(from = 0.0)*/ + val factorAtMin: Float = StandardResistanceFactor, + /*@FloatRange(from = 0.0)*/ + val factorAtMax: Float = StandardResistanceFactor +) { + fun computeResistance(overflow: Float): Float { + val factor = if (overflow < 0) factorAtMin else factorAtMax + if (factor == 0f) return 0f + val progress = (overflow / basis).coerceIn(-1f, 1f) + return basis / factor * sin(progress * PI.toFloat() / 2) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ResistanceConfig) return false + + if (basis != other.basis) return false + if (factorAtMin != other.factorAtMin) return false + if (factorAtMax != other.factorAtMax) return false + + return true + } + + override fun hashCode(): Int { + var result = basis.hashCode() + result = 31 * result + factorAtMin.hashCode() + result = 31 * result + factorAtMax.hashCode() + return result + } + + override fun toString(): String { + return "ResistanceConfig(basis=$basis, factorAtMin=$factorAtMin, factorAtMax=$factorAtMax)" + } +} + +/** + * Given an offset x and a set of anchors, return a list of anchors: + * 1. [ ] if the set of anchors is empty, + * 2. [ x' ] if x is equal to one of the anchors, accounting for a small rounding error, where x' + * is x rounded to the exact value of the matching anchor, + * 3. [ min ] if min is the minimum anchor and x < min, + * 4. [ max ] if max is the maximum anchor and x > max, or + * 5. [ a , b ] if a and b are anchors such that a < x < b and b - a is minimal. + */ +private fun findBounds( + offset: Float, + anchors: Set +): List { + // Find the anchors the target lies between with a little bit of rounding error. + val a = anchors.filter { it <= offset + 0.001 }.maxOrNull() + val b = anchors.filter { it >= offset - 0.001 }.minOrNull() + + return when { + a == null -> + // case 1 or 3 + listOfNotNull(b) + b == null -> + // case 4 + listOf(a) + a == b -> + // case 2 + // Can't return offset itself here since it might not be exactly equal + // to the anchor, despite being considered an exact match. + listOf(a) + else -> + // case 5 + listOf(a, b) + } +} + +private fun computeTarget( + offset: Float, + lastValue: Float, + anchors: Set, + thresholds: (Float, Float) -> Float, + velocity: Float, + velocityThreshold: Float +): Float { + val bounds = findBounds(offset, anchors) + return when (bounds.size) { + 0 -> lastValue + 1 -> bounds[0] + else -> { + val lower = bounds[0] + val upper = bounds[1] + if (lastValue <= offset) { + // Swiping from lower to upper (positive). + if (velocity >= velocityThreshold) { + return upper + } else { + val threshold = thresholds(lower, upper) + if (offset < threshold) lower else upper + } + } else { + // Swiping from upper to lower (negative). + if (velocity <= -velocityThreshold) { + return lower + } else { + val threshold = thresholds(upper, lower) + if (offset > threshold) upper else lower + } + } + } + } +} + +private fun Map.getOffset(state: T): Float? { + return entries.firstOrNull { it.value == state }?.key +} + +/** + * Contains useful defaults for [swipeable] and [SwipeableState]. + */ +object SwipeableDefaults { + /** + * The default animation used by [SwipeableState]. + */ + val AnimationSpec = SpringSpec() + + /** + * The default velocity threshold (1.8 dp per millisecond) used by [swipeable]. + */ + val VelocityThreshold = 125.dp + + /** + * A stiff resistance factor which indicates that swiping isn't available right now. + */ + const val StiffResistanceFactor = 20f + + /** + * A standard resistance factor which indicates that the user has run out of things to see. + */ + const val StandardResistanceFactor = 10f + + /** + * The default resistance config used by [swipeable]. + * + * This returns `null` if there is one anchor. If there are at least two anchors, it returns + * a [ResistanceConfig] with the resistance basis equal to the distance between the two bounds. + */ + fun resistanceConfig( + anchors: Set, + factorAtMin: Float = StandardResistanceFactor, + factorAtMax: Float = StandardResistanceFactor + ): ResistanceConfig? { + return if (anchors.size <= 1) { + null + } else { + val basis = anchors.maxOrNull()!! - anchors.minOrNull()!! + ResistanceConfig(basis, factorAtMin, factorAtMax) + } + } +} +//TODO revisit to check if this become "public" from material. If so then rely directly on material API. +val SwipeableState.PreUpPostDownNestedScrollConnection: NestedScrollConnection + get() = object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val delta = available.toFloat() + return if (delta < 0 && source == NestedScrollSource.Drag) { + performDrag(delta).toOffset() + } else { + Offset.Zero + } + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + return if (source == NestedScrollSource.Drag) { + performDrag(available.toFloat()).toOffset() + } else { + Offset.Zero + } + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val toFling = Offset(available.x, available.y).toFloat() + return if (toFling < 0 && offset.value > minBound) { + performFling(velocity = toFling) + // since we go to the anchor with tween settling, consume all for the best UX + available + } else { + Velocity.Zero + } + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + performFling(velocity = Offset(available.x, available.y).toFloat()) + return available + } + + private fun Float.toOffset(): Offset = Offset(0f, this) + + private fun Offset.toFloat(): Float = this.y + } diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/ControlTokens.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/ControlTokens.kt index 0c2b7ae49..100d59351 100644 --- a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/ControlTokens.kt +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/ControlTokens.kt @@ -19,6 +19,7 @@ class ControlTokens { AvatarGroup, Button, CheckBox, + Drawer, FloatingActionButton, RadioButton, ToggleSwitch @@ -31,6 +32,7 @@ class ControlTokens { ControlType.AvatarGroup -> AvatarGroupTokens() ControlType.Button -> ButtonTokens() ControlType.CheckBox -> CheckBoxTokens() + ControlType.Drawer -> DrawerTokens() ControlType.FloatingActionButton -> FABTokens() ControlType.RadioButton -> RadioButtonTokens() ControlType.ToggleSwitch -> ToggleSwitchTokens() diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/DrawerTokens.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/DrawerTokens.kt new file mode 100644 index 000000000..dbb92b32b --- /dev/null +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/DrawerTokens.kt @@ -0,0 +1,57 @@ +package com.microsoft.fluentui.theme.token.controlTokens + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import com.microsoft.fluentui.theme.FluentTheme +import com.microsoft.fluentui.theme.token.AliasTokens +import com.microsoft.fluentui.theme.token.ControlToken +import com.microsoft.fluentui.theme.token.GlobalTokens +import kotlinx.parcelize.Parcelize + +/** + * Possible values of [BehaviorType]. + */ +enum class BehaviorType { + BOTTOM, TOP, LEFT, RIGHT +} + +@Parcelize +open class DrawerTokens : ControlToken, Parcelable { + companion object { + const val Type: String = "Drawer" + } + + @Composable + open fun backgroundColor(type: BehaviorType): Color = + FluentTheme.aliasTokens.neutralBackgroundColor[AliasTokens.NeutralBackgroundColorTokens.Background2].value( + themeMode = FluentTheme.themeMode + ) + + @Composable + open fun handleColor(type: BehaviorType): Color = + FluentTheme.aliasTokens.neutralStrokeColor[AliasTokens.NeutralStrokeColorTokens.Stroke1].value( + themeMode = FluentTheme.themeMode + ) + + @Composable + open fun elevation(type: BehaviorType): Dp = + FluentTheme.globalTokens.elevation[GlobalTokens.ShadowTokens.Shadow28] + + @Composable + open fun borderRadius(type: BehaviorType): Dp { + return when (type) { + BehaviorType.TOP, BehaviorType.BOTTOM -> FluentTheme.globalTokens.borderRadius[GlobalTokens.BorderRadiusTokens.XLarge] + BehaviorType.LEFT, BehaviorType.RIGHT -> FluentTheme.globalTokens.borderRadius[GlobalTokens.BorderRadiusTokens.None] + } + } + + @Composable + open fun scrimColor(type: BehaviorType): Color = + FluentTheme.globalTokens.neutralColor[GlobalTokens.NeutralColorTokens.Black] + + @Composable + open fun scrimOpacity(type: BehaviorType): Float = + FluentTheme.globalTokens.opacity[GlobalTokens.OpacityTokens.Opacity32] +} \ No newline at end of file diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/util/Utils.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/util/Utils.kt new file mode 100644 index 000000000..b6e6b9cc9 --- /dev/null +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/util/Utils.kt @@ -0,0 +1,13 @@ +package com.microsoft.fluentui.util + +import android.content.res.Resources +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +fun pxToDp(value: Float) = (value / Resources + .getSystem() + .displayMetrics.density).dp + +fun dpToPx(value: Dp) = (value * Resources + .getSystem() + .displayMetrics.density).value \ No newline at end of file diff --git a/fluentui_drawer/build.gradle b/fluentui_drawer/build.gradle index 735cd3906..570101e42 100644 --- a/fluentui_drawer/build.gradle +++ b/fluentui_drawer/build.gradle @@ -30,6 +30,21 @@ android { } } + buildFeatures { + compose true + } + composeOptions { + kotlinCompilerExtensionVersion composeVersion + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + useIR = true + } + } dependencies { @@ -44,6 +59,11 @@ dependencies { testImplementation "junit:junit:$junitVersion" androidTestImplementation "androidx.test.ext:junit:$extJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion" + + implementation "androidx.compose.ui:ui:$composeVersion" + implementation "androidx.compose.ui:ui-util:$composeVersion" + implementation "androidx.compose.material:material:$composeVersion" + implementation "androidx.constraintlayout:constraintlayout-compose:$constraintLayoutComposeVersion" } task sourceJar(type: Jar) { diff --git a/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/drawer/Drawer.kt b/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/drawer/Drawer.kt new file mode 100644 index 000000000..229e66473 --- /dev/null +++ b/fluentui_drawer/src/main/java/com/microsoft/fluentui/tokenized/drawer/Drawer.kt @@ -0,0 +1,749 @@ +package com.microsoft.fluentui.tokenized.drawer + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.TweenSpec +import androidx.compose.foundation.* +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.Surface +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusTarget +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.dismiss +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.* +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import androidx.constraintlayout.compose.ConstraintLayout +import com.microsoft.fluentui.compose.* +import com.microsoft.fluentui.drawer.R +import com.microsoft.fluentui.theme.FluentTheme +import com.microsoft.fluentui.theme.token.ControlTokens +import com.microsoft.fluentui.theme.token.controlTokens.BehaviorType +import com.microsoft.fluentui.theme.token.controlTokens.DrawerTokens +import com.microsoft.fluentui.util.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + + +/** + * Possible values of [DrawerState]. + */ +enum class DrawerValue { + /** + * The state of the drawer when it is closed. + */ + Closed, + + /** + * The state of the drawer when it is open. + */ + Open, + + /** + * The state of the bottom drawer when it is expanded (i.e. at 100% height). + */ + Expanded +} + +/** + * State of the [Drawer] composable. + * + * @param initialValue The initial value of the state. + * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change. + */ +class DrawerState( + private val initialValue: DrawerValue = DrawerValue.Closed, + confirmStateChange: (DrawerValue) -> Boolean = { true } +) { + + internal val swipeableState = SwipeableState( + initialValue = initialValue, + animationSpec = AnimationSpec, + confirmStateChange = confirmStateChange + ) + var enable: Boolean by mutableStateOf(false) + + /** + * Whether the drawer is open. + */ + val isOpen: Boolean + get() = currentValue == DrawerValue.Open + + /** + * Whether the drawer is closed. + */ + val isClosed: Boolean + get() = currentValue == DrawerValue.Closed + + /** + * The current value of the state. + * + * If no swipe or animation is in progress, this corresponds to the start the drawer + * currently in. If a swipe or an animation is in progress, this corresponds the state drawer + * was in before the swipe or animation started. + */ + val currentValue: DrawerValue + get() = swipeableState.currentValue + + /** + * Whether the state is currently animating. + */ + val isAnimationRunning: Boolean + get() = swipeableState.isAnimationRunning + + var animationInProgress: Boolean = false + + /** + * Open the drawer with animation and suspend until it if fully opened or animation has been + * cancelled. This method will throw [CancellationException] if the animation is + * interrupted + * + * @return the reason the open animation ended + */ + suspend fun open() { + enable = true + animationInProgress = true + delay(50) + animateTo(DrawerValue.Open, AnimationSpec) + animationInProgress = false + } + + /** + * Close the drawer with animation and suspend until it if fully closed or animation has been + * cancelled. This method will throw [CancellationException] if the animation is + * interrupted + * + * @return the reason the close animation ended + */ + suspend fun close() { + animationInProgress = true + animateTo(DrawerValue.Closed, AnimationSpec) + animationInProgress = false + enable = false + } + + /** + * Set the state of the drawer with specific animation + * + * @param targetValue The new value to animate to. + * @param anim The animation that will be used to animate to the new value. + */ + suspend fun animateTo(targetValue: DrawerValue, anim: AnimationSpec) = + swipeableState.animateTo(targetValue, anim) + + /** + * Set the state without any animation and suspend until it's set + * + * @param targetValue The new target value + */ + suspend fun snapTo(targetValue: DrawerValue) = + swipeableState.snapTo(targetValue) + + /** + * The target value of the drawer state. + * + * If a swipe is in progress, this is the value that the Drawer would animate to if the + * swipe finishes. If an animation is running, this is the target value of that animation. + * Finally, if no swipe or animation is in progress, this is the same as the [currentValue]. + */ + val targetValue: DrawerValue + get() = swipeableState.targetValue + + /** + * The current position (in pixels) of the drawer sheet. + */ + val offset: State + get() = swipeableState.offset + + fun performDrag(drag: Float): Float = swipeableState.performDrag(drag) + + suspend fun performFling(velocity: Float) = swipeableState.performFling(velocity) + + val nestedScrollConnection = swipeableState.PreUpPostDownNestedScrollConnection + + companion object { + /** + * The default [Saver] implementation for [DrawerState]. + */ + fun Saver(confirmStateChange: (DrawerValue) -> Boolean) = + Saver( + save = { it.currentValue }, + restore = { DrawerState(it, confirmStateChange) } + ) + } +} + +/** + * Create and [remember] a [DrawerState]. + * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change. + */ +@Composable +fun rememberDrawerState(confirmStateChange: (DrawerValue) -> Boolean = { true }): DrawerState { + return rememberSaveable(saver = DrawerState.Saver(confirmStateChange)) { + DrawerState(DrawerValue.Closed, confirmStateChange) + } +} + +private class DrawerPositionProvider : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize + ): IntOffset { + return IntOffset(0, 0) + } +} + +private fun calculateFraction(a: Float, b: Float, pos: Float) = + ((pos - a) / (b - a)).coerceIn(0f, 1f) + +@Composable +private fun Scrim( + open: Boolean, + onClose: () -> Unit, + fraction: () -> Float, + color: Color +) { + val dismissDrawer = if (open) { + Modifier.pointerInput(onClose) { detectTapGestures { onClose() } } + } else { + Modifier + } + + Canvas( + Modifier + .fillMaxSize() + .then(dismissDrawer) + .testTag(DRAWER_SCRIM_TAG) + ) { + drawRect(color, alpha = fraction()) + } +} + +private val EndDrawerPadding = 56.dp +private val DrawerVelocityThreshold = 400.dp + +private val AnimationSpec = TweenSpec(durationMillis = 256) + +private const val DrawerOpenFraction = 0.5f + +//Tag use for testing +private const val DRAWER_HANDLE_TAG = "Drawer Handle" +private const val DRAWER_CONTENT_TAG = "Drawer Content" +private const val DRAWER_SCRIM_TAG = "Drawer Scrim" + +//Drawer Handle height + padding +private val DrawerHandleHeightOffset = 20.dp +/** + * + * + * Side drawers block interaction with the rest of an app’s content with a scrim. + * They are elevated above most of the app’s UI and don’t affect the screen’s layout grid. + * + * @param drawerContent composable that represents content inside the drawer + * @param modifier optional modifier for the drawer + * @param drawerState state of the drawer + * @param drawerShape shape of the drawer sheet + * @param drawerElevation drawer sheet elevation. This controls the size of the shadow below the + * drawer sheet + * @param drawerBackgroundColor background color to be used for the drawer sheet + * @param drawerContentColor color of the content to use inside the drawer sheet. Defaults to + * either the matching content color for [drawerBackgroundColor], or, if it is not a color from + * the theme, this will keep the same value set above this Surface. + * @param scrimColor color of the scrim that obscures content when the drawer is open + * + * @throws IllegalStateException when parent has [Float.POSITIVE_INFINITY] width + */ +@Composable +private fun HorizontalDrawer( + modifier: Modifier, + behaviorType: BehaviorType, + drawerState: DrawerState, + drawerShape: Shape, + drawerElevation: Dp, + drawerBackgroundColor: Color, + drawerContentColor: Color, + scrimColor: Color, + scrimVisible: Boolean, + onDismiss: () -> Unit, + drawerContent: @Composable () -> Unit +) { + BoxWithConstraints(modifier.fillMaxSize()) { + val modalDrawerConstraints = constraints + + // TODO : think about Infinite max bounds case + if (!modalDrawerConstraints.hasBoundedWidth) { + throw IllegalStateException("Drawer shouldn't have infinite width") + } + + val fullWidth = modalDrawerConstraints.maxWidth.toFloat() + var drawerWidth by remember(fullWidth) { mutableStateOf(fullWidth) } + //Hack to get exact drawerHeight wrt to content. + val visible = remember { mutableStateOf(true) } + if (visible.value) { + Box( + modifier = Modifier + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + drawerWidth = placeable.width.toFloat() + visible.value = false + } + } + ) { + drawerContent() + } + } else { + val paddingPx = pxToDp(max(dpToPx(EndDrawerPadding), (fullWidth - drawerWidth))) + val leftSlide = behaviorType == BehaviorType.LEFT + + val minValue = + modalDrawerConstraints.maxWidth.toFloat() * (if (leftSlide) (-1F) else (1F)) + val maxValue = 0f + + val anchors = mapOf(minValue to DrawerValue.Closed, maxValue to DrawerValue.Open) + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + Box( + Modifier.swipeable( + state = drawerState.swipeableState, + anchors = anchors, + thresholds = { _, _ -> FixedThreshold(pxToDp(value = drawerWidth / 2)) }, + orientation = Orientation.Horizontal, + enabled = false, + reverseDirection = isRtl, + velocityThreshold = DrawerVelocityThreshold, + resistance = null + ) + ) { + Scrim( + open = drawerState.isOpen, + onClose = onDismiss, + fraction = { + calculateFraction(minValue, maxValue, drawerState.offset.value) + }, + color = if (scrimVisible) scrimColor else Color.Transparent, + ) + + Surface( + modifier = with(LocalDensity.current) { + Modifier + .sizeIn( + minWidth = modalDrawerConstraints.minWidth.toDp(), + minHeight = modalDrawerConstraints.minHeight.toDp(), + maxWidth = modalDrawerConstraints.maxWidth.toDp(), + maxHeight = modalDrawerConstraints.maxHeight.toDp() + ) + } + .offset { IntOffset(drawerState.offset.value.roundToInt(), 0) } + .padding( + start = if (leftSlide) 0.dp else paddingPx, + end = if (leftSlide) paddingPx else 0.dp + ) + .semantics { + if (drawerState.isOpen) { + dismiss { + onDismiss() + true + } + } + }, + shape = drawerShape, + color = drawerBackgroundColor, + contentColor = drawerContentColor, + elevation = drawerElevation + ) { + Column(Modifier + .draggable( + orientation = Orientation.Horizontal, + state = rememberDraggableState { delta -> + drawerState.performDrag(delta) + }, + onDragStopped = { velocity -> + launch { + drawerState.performFling( + velocity + ) + if (drawerState.isClosed) { + onDismiss() + } + } + }, + ) + .testTag(DRAWER_CONTENT_TAG), content = { drawerContent() }) + } + } + } + } +} + +@Composable +private fun VerticalDrawer( + modifier: Modifier, + behaviorType: BehaviorType, + drawerState: DrawerState, + drawerShape: Shape, + drawerElevation: Dp, + drawerBackgroundColor: Color, + drawerContentColor: Color, + drawerHandleColor: Color, + scrimColor: Color, + scrimVisible: Boolean, + expandable: Boolean, + onDismiss: () -> Unit, + drawerContent: @Composable () -> Unit +) { + BoxWithConstraints(modifier.fillMaxSize()) { + val fullHeight = constraints.maxHeight.toFloat() + var drawerHeight by remember(fullHeight) { mutableStateOf(fullHeight) } + + //Get exact drawerHeight first. + val visible = remember { mutableStateOf(true) } + + if (visible.value) { + Box( + modifier = Modifier + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + visible.value = false + drawerHeight = + placeable.height.toFloat() + dpToPx(DrawerHandleHeightOffset) + } + } + ) { + drawerContent() + } + } else { + val allowedHeight = fullHeight * DrawerOpenFraction + val minHeight = 0f + val topCloseHeight = minHeight + val topOpenHeight = min(allowedHeight, drawerHeight) + + val bottomOpenStateY = max(allowedHeight, fullHeight - drawerHeight) + val bottomExpandedStateY = max(minHeight, fullHeight - drawerHeight) + + val bottomDrawerHeight = + if (expandable) drawerHeight else min(allowedHeight, drawerHeight) + + val minValue: Float + val maxValue: Float + + val anchors = if (behaviorType == BehaviorType.TOP) { + minValue = topCloseHeight + maxValue = topOpenHeight + mapOf( + topCloseHeight to DrawerValue.Closed, + topOpenHeight to DrawerValue.Open + ) + } else { + minValue = fullHeight + maxValue = bottomOpenStateY + if (drawerHeight < bottomOpenStateY || !expandable) { + mapOf( + fullHeight to DrawerValue.Closed, + bottomOpenStateY to DrawerValue.Open + ) + } else { + mapOf( + fullHeight to DrawerValue.Closed, + bottomOpenStateY to DrawerValue.Open, + bottomExpandedStateY to DrawerValue.Expanded + ) + } + } + + val drawerConstraints = with(LocalDensity.current) { + Modifier + .sizeIn( + maxWidth = constraints.maxWidth.toDp(), + maxHeight = constraints.maxHeight.toDp() + ) + } + val nestedScroll = if (behaviorType == BehaviorType.BOTTOM) { + Modifier.nestedScroll(drawerState.nestedScrollConnection) + } else { + Modifier + } + + val swipeable = Modifier + .then(nestedScroll) + .swipeable( + state = drawerState.swipeableState, + anchors = anchors, + orientation = Orientation.Vertical, + enabled = false, + resistance = null + ) + + Box(swipeable) { + Scrim( + open = !drawerState.isClosed, + onClose = onDismiss, + fraction = { + calculateFraction(minValue, maxValue, drawerState.offset.value) + }, + color = if (scrimVisible) scrimColor else Color.Transparent, + ) + + if (behaviorType == BehaviorType.BOTTOM) { + Surface( + drawerConstraints + .offset { IntOffset(x = 0, y = drawerState.offset.value.roundToInt()) } + .semantics { + if (drawerState.isOpen) { + dismiss { + onDismiss() + true + } + } + } + .height(pxToDp(bottomDrawerHeight)) + .onGloballyPositioned { layoutCoordinates -> + if (!drawerState.animationInProgress && (drawerState.isClosed || layoutCoordinates.size.height == 0) + ) { + onDismiss() + } + + } + .focusable(false), + shape = drawerShape, + color = drawerBackgroundColor, + contentColor = drawerContentColor, + elevation = drawerElevation + ) { + Column { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(vertical = 8.dp) + .fillMaxWidth() + .draggable( + orientation = Orientation.Vertical, + state = rememberDraggableState { delta -> + drawerState.performDrag(delta) + }, + onDragStopped = { velocity -> + launch { + drawerState.performFling( + velocity + ) + if (drawerState.isClosed) { + onDismiss() + } + } + }, + ) + .testTag(DRAWER_HANDLE_TAG) + ) { + Icon( + painterResource(id = R.drawable.ic_drawer_handle), + contentDescription = null, + tint = drawerHandleColor + ) + } + Column(modifier = Modifier + .verticalScroll( + rememberScrollState() + ) + .height(pxToDp(bottomDrawerHeight) - DrawerHandleHeightOffset) + .testTag(DRAWER_CONTENT_TAG), content = { drawerContent() }) + } + } + } else { + Surface( + drawerConstraints + .offset { IntOffset(0, 0) } + .semantics { + if (drawerState.isOpen) { + dismiss { + onDismiss() + true + } + } + } + .height( + pxToDp(drawerState.offset.value) + ) + .focusable(false), + shape = drawerShape, + color = drawerBackgroundColor, + contentColor = drawerContentColor, + elevation = drawerElevation + ) { + ConstraintLayout(modifier = Modifier.padding(bottom = 8.dp)) { + val (drawerContentConstrain, drawerHandleConstrain) = createRefs() + Column(modifier = Modifier + .offset { IntOffset(0, 0) } + .padding(bottom = 8.dp) + .constrainAs(drawerContentConstrain) { + top.linkTo(parent.top) + bottom.linkTo(drawerHandleConstrain.top) + } + .focusTarget() + .testTag(DRAWER_CONTENT_TAG), content = { drawerContent() } + ) + Column(horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .constrainAs(drawerHandleConstrain) { + top.linkTo(drawerContentConstrain.bottom) + bottom.linkTo(parent.bottom) + } + .fillMaxWidth() + .draggable( + orientation = Orientation.Vertical, + state = rememberDraggableState { delta -> + drawerState.performDrag(delta) + }, + onDragStopped = { velocity -> + launch { + drawerState.performFling( + velocity + ) + if (drawerState.isClosed) { + onDismiss() + } + } + }, + ) + .testTag(DRAWER_HANDLE_TAG) + ) { + Icon( + painterResource(id = R.drawable.ic_drawer_handle), + contentDescription = null, + tint = drawerHandleColor + ) + } + } + } + } + } + } + } +} + +internal val LocalDrawerTokens = compositionLocalOf { DrawerTokens() } + +@Composable +private fun getDrawerTokens(): DrawerTokens { + return LocalDrawerTokens.current +} + +/** + * + * Drawer block interaction with the rest of an app’s content with a scrim. + * They are elevated above most of the app’s UI and don’t affect the screen’s layout grid. + * + * @param modifier optional modifier for the drawer + * @param behaviorType opening behaviour of drawer. Default is BOTTOM + * @param drawerState state of the drawer + * @param expandable if true drawer would expand on drag else drawer open till fixed/wrapped height. + * The default value is false + * @param scrimVisible create obscures background when scrim visible set to true when the drawer is open. The default value is true + * @param drawerTokens tokens to provide appearance values. If not provided then drawer tokens will be picked from [AppThemeController] + * @param drawerContent composable that represents content inside the drawer + * + * @throws IllegalStateException when parent has [Float.POSITIVE_INFINITY] width + */ + +@Composable +fun Drawer( + modifier: Modifier = Modifier, + behaviorType: BehaviorType = BehaviorType.BOTTOM, + drawerState: DrawerState = rememberDrawerState(), + expandable: Boolean = false, + scrimVisible: Boolean = true, + drawerTokens: DrawerTokens? = null, + drawerContent: @Composable () -> Unit +) { + if (drawerState.enable) { + val tokens = drawerTokens + ?: FluentTheme.controlTokens.tokens[ControlTokens.ControlType.Drawer] as DrawerTokens + + val popupPositionProvider = DrawerPositionProvider() + val scope = rememberCoroutineScope() + val close: () -> Unit = { + scope.launch { drawerState.close() } + } + + CompositionLocalProvider(LocalDrawerTokens provides tokens) { + Popup( + onDismissRequest = close, + popupPositionProvider = popupPositionProvider, + properties = PopupProperties(focusable = true) + ) + { + val drawerShape: Shape = + when (behaviorType) { + BehaviorType.BOTTOM -> RoundedCornerShape(topStart = getDrawerTokens().borderRadius(type = behaviorType), topEnd = getDrawerTokens().borderRadius(type = behaviorType)) + BehaviorType.TOP -> RoundedCornerShape(bottomStart = getDrawerTokens().borderRadius(type = behaviorType), bottomEnd = getDrawerTokens().borderRadius(type = behaviorType)) + else -> RoundedCornerShape(getDrawerTokens().borderRadius(type = behaviorType)) + } + val drawerElevation: Dp = getDrawerTokens().elevation(type = behaviorType) + val drawerBackgroundColor: Color = + getDrawerTokens().backgroundColor(type = behaviorType) + val drawerContentColor: Color = Color.Transparent + val drawerHandleColor: Color = getDrawerTokens().handleColor(type = behaviorType) + val scrimOpacity: Float = getDrawerTokens().scrimOpacity(type = behaviorType) + val scrimColor: Color = + getDrawerTokens().scrimColor(type = behaviorType).copy(alpha = scrimOpacity) + + when (behaviorType) { + BehaviorType.BOTTOM, BehaviorType.TOP -> VerticalDrawer( + behaviorType = behaviorType, + modifier = modifier, + drawerState = drawerState, + drawerShape = drawerShape, + drawerElevation = drawerElevation, + drawerBackgroundColor = drawerBackgroundColor, + drawerContentColor = drawerContentColor, + drawerHandleColor = drawerHandleColor, + scrimColor = scrimColor, + scrimVisible = scrimVisible, + expandable = expandable, + onDismiss = close, + drawerContent = drawerContent + ) + + BehaviorType.LEFT, BehaviorType.RIGHT -> HorizontalDrawer( + behaviorType = behaviorType, + modifier = modifier, + drawerState = drawerState, + drawerShape = drawerShape, + drawerElevation = drawerElevation, + drawerBackgroundColor = drawerBackgroundColor, + drawerContentColor = drawerContentColor, + scrimColor = scrimColor, + scrimVisible = scrimVisible, + onDismiss = close, + drawerContent = drawerContent + ) + } + } + } + } +} \ No newline at end of file