[go: nahoru, domu]

Add positional thresholds to BottomDrawerLayout

This CL introduces a positional thresholds parameter in stateDraggable.
Thresholds are specified as a lambda that takes a pair of anchors and
returns a value between them. The order of the anchors matters since it
indicates the drag direction (i.e. from the first anchor to the second).
Two default threshold constructors are provided: fixed and fractional.
BottomDrawerLayout uses a fixed threshold of 56dp or an app bar height.

Bug: 158805662
Bug: 158806527
Test: Ran DrawerTest and SwitchTest
Relnote: "Added thresholds param in stateDraggable to specify thresholds
between anchors. This was used to set a 56dp threshold in bottom drawer.
Also BottomDrawerLayout now uses a separate BottomDrawerState enum."
Change-Id: I533fad3d3bf9b95f702156e321aa15a84e81819b
diff --git a/ui/ui-material/src/androidTest/java/androidx/ui/material/DrawerScreenshotTest.kt b/ui/ui-material/src/androidTest/java/androidx/ui/material/DrawerScreenshotTest.kt
index 5a21b05..57d6209 100644
--- a/ui/ui-material/src/androidTest/java/androidx/ui/material/DrawerScreenshotTest.kt
+++ b/ui/ui-material/src/androidTest/java/androidx/ui/material/DrawerScreenshotTest.kt
@@ -48,7 +48,7 @@
     @get:Rule
     val screenshotRule = AndroidXScreenshotTestRule(GOLDEN_MATERIAL)
 
-    private fun ComposeTestRule.setBottomDrawer(drawerState: DrawerState) {
+    private fun ComposeTestRule.setBottomDrawer(drawerState: BottomDrawerState) {
         setMaterialContent {
             Box(Modifier.size(10.dp, 100.dp).testTag("container")) {
                 BottomDrawerLayout(
@@ -76,7 +76,7 @@
 
     @Test
     fun bottomDrawer_closed() {
-        composeTestRule.setBottomDrawer(DrawerState.Closed)
+        composeTestRule.setBottomDrawer(BottomDrawerState.Closed)
         assertScreenshotAgainstGolden("bottomDrawer_closed")
     }
 
@@ -88,7 +88,7 @@
 
     @Test
     fun bottomDrawer_opened() {
-        composeTestRule.setBottomDrawer(DrawerState.Opened)
+        composeTestRule.setBottomDrawer(BottomDrawerState.Opened)
         assertScreenshotAgainstGolden("bottomDrawer_opened")
     }
 
diff --git a/ui/ui-material/src/androidTest/java/androidx/ui/material/DrawerTest.kt b/ui/ui-material/src/androidTest/java/androidx/ui/material/DrawerTest.kt
index 510382b..1e6e627 100644
--- a/ui/ui-material/src/androidTest/java/androidx/ui/material/DrawerTest.kt
+++ b/ui/ui-material/src/androidTest/java/androidx/ui/material/DrawerTest.kt
@@ -31,10 +31,12 @@
 import androidx.ui.graphics.Color
 import androidx.ui.layout.fillMaxSize
 import androidx.ui.layout.rtl
+import androidx.ui.test.GestureScope
 import androidx.ui.test.assertIsEqualTo
 import androidx.ui.test.assertLeftPositionInRootIsEqualTo
 import androidx.ui.test.assertTopPositionInRootIsEqualTo
 import androidx.ui.test.assertWidthIsEqualTo
+import androidx.ui.test.center
 import androidx.ui.test.createComposeRule
 import androidx.ui.test.doGesture
 import androidx.ui.test.findByTag
@@ -42,6 +44,7 @@
 import androidx.ui.test.runOnIdleCompose
 import androidx.ui.test.runOnUiThread
 import androidx.ui.test.sendClick
+import androidx.ui.test.sendSwipe
 import androidx.ui.test.sendSwipeDown
 import androidx.ui.test.sendSwipeLeft
 import androidx.ui.test.sendSwipeRight
@@ -110,7 +113,7 @@
     @Test
     fun bottomDrawer_testOffset_whenOpened() {
         composeTestRule.setMaterialContent {
-            BottomDrawerLayout(DrawerState.Opened, {}, drawerContent = {
+            BottomDrawerLayout(BottomDrawerState.Opened, {}, drawerContent = {
                 Box(Modifier.fillMaxSize().testTag("content"))
             }, bodyContent = emptyContent())
         }
@@ -126,7 +129,7 @@
     fun bottomDrawer_testOffset_whenClosed() {
         var position: Offset? = null
         composeTestRule.setMaterialContent {
-            BottomDrawerLayout(DrawerState.Closed, {}, drawerContent = {
+            BottomDrawerLayout(BottomDrawerState.Closed, {}, drawerContent = {
                 Box(Modifier.fillMaxSize().onPositioned { coords: LayoutCoordinates ->
                     position = coords.localToRoot(Offset.Zero)
                 })
@@ -240,7 +243,7 @@
         var openedHeight: Int? = null
         var openedLatch: CountDownLatch? = null
         var closedLatch: CountDownLatch? = CountDownLatch(1)
-        val drawerState = mutableStateOf(DrawerState.Closed)
+        val drawerState = mutableStateOf(BottomDrawerState.Closed)
         composeTestRule.setMaterialContent {
             BottomDrawerLayout(drawerState.value, { drawerState.value = it },
                 drawerContent = {
@@ -269,7 +272,7 @@
         // When the drawer state is set to Opened
         openedLatch = CountDownLatch(1)
         runOnIdleCompose {
-            drawerState.value = DrawerState.Opened
+            drawerState.value = BottomDrawerState.Opened
         }
         // Then the drawer should be opened
         assertThat(openedLatch.await(5, TimeUnit.SECONDS)).isTrue()
@@ -277,7 +280,7 @@
         // When the drawer state is set to Closed
         closedLatch = CountDownLatch(1)
         runOnIdleCompose {
-            drawerState.value = DrawerState.Closed
+            drawerState.value = BottomDrawerState.Closed
         }
         // Then the drawer should be closed
         assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue()
@@ -287,7 +290,7 @@
     fun bottomDrawer_bodyContent_clickable() {
         var drawerClicks = 0
         var bodyClicks = 0
-        val drawerState = mutableStateOf(DrawerState.Closed)
+        val drawerState = mutableStateOf(BottomDrawerState.Closed)
         composeTestRule.setMaterialContent {
             // emulate click on the screen
             BottomDrawerLayout(drawerState.value, { drawerState.value = it },
@@ -314,7 +317,7 @@
         }
 
         runOnUiThread {
-            drawerState.value = DrawerState.Opened
+            drawerState.value = BottomDrawerState.Opened
         }
         sleep(100) // TODO(147586311): remove this sleep when opening the drawer triggers a wait
 
@@ -394,7 +397,7 @@
 
     @Test
     fun bottomDrawer_openBySwipe() {
-        val drawerState = mutableStateOf(DrawerState.Closed)
+        val drawerState = mutableStateOf(BottomDrawerState.Closed)
         composeTestRule.setMaterialContent {
             // emulate click on the screen
             Box(Modifier.testTag("Drawer")) {
@@ -412,14 +415,96 @@
             .doGesture { sendSwipeUp() }
 
         runOnIdleCompose {
-            assertThat(drawerState.value).isEqualTo(DrawerState.Opened)
+            assertThat(drawerState.value).isEqualTo(BottomDrawerState.Expanded)
         }
 
         findByTag("Drawer")
             .doGesture { sendSwipeDown() }
 
         runOnIdleCompose {
-            assertThat(drawerState.value).isEqualTo(DrawerState.Closed)
+            assertThat(drawerState.value).isEqualTo(BottomDrawerState.Closed)
         }
     }
+
+    @Test
+    fun bottomDrawer_openBySwipe_thresholds() {
+        val drawerState = mutableStateOf(BottomDrawerState.Closed)
+        composeTestRule.setMaterialContent {
+            // emulate click on the screen
+            Box(Modifier.testTag("Drawer")) {
+                BottomDrawerLayout(drawerState.value, { drawerState.value = it },
+                    drawerContent = {
+                        Box(Modifier.fillMaxSize().drawBackground(Color.Magenta))
+                    },
+                    bodyContent = {
+                        Box(Modifier.fillMaxSize().drawBackground(Color.Red))
+                    })
+            }
+        }
+        val threshold = with (composeTestRule.density) { BottomDrawerThreshold.toPx() }
+
+        findByTag("Drawer")
+            .doGesture { sendSwipeUpBy(threshold / 2) }
+
+        runOnIdleCompose {
+            assertThat(drawerState.value).isEqualTo(BottomDrawerState.Closed)
+        }
+
+        findByTag("Drawer")
+            .doGesture { sendSwipeUpBy(threshold) }
+
+        runOnIdleCompose {
+            assertThat(drawerState.value).isEqualTo(BottomDrawerState.Opened)
+        }
+
+        findByTag("Drawer")
+            .doGesture { sendSwipeUpBy(threshold / 2) }
+
+        runOnIdleCompose {
+            assertThat(drawerState.value).isEqualTo(BottomDrawerState.Opened)
+        }
+
+        findByTag("Drawer")
+            .doGesture { sendSwipeUpBy(threshold) }
+
+        runOnIdleCompose {
+            assertThat(drawerState.value).isEqualTo(BottomDrawerState.Expanded)
+        }
+
+        findByTag("Drawer")
+            .doGesture { sendSwipeDownBy(threshold / 2) }
+
+        runOnIdleCompose {
+            assertThat(drawerState.value).isEqualTo(BottomDrawerState.Expanded)
+        }
+
+        findByTag("Drawer")
+            .doGesture { sendSwipeDownBy(threshold) }
+
+        runOnIdleCompose {
+            assertThat(drawerState.value).isEqualTo(BottomDrawerState.Opened)
+        }
+
+        findByTag("Drawer")
+            .doGesture { sendSwipeDownBy(threshold / 2) }
+
+        runOnIdleCompose {
+            assertThat(drawerState.value).isEqualTo(BottomDrawerState.Opened)
+        }
+
+        findByTag("Drawer")
+            .doGesture { sendSwipeDownBy(threshold) }
+
+        runOnIdleCompose {
+            assertThat(drawerState.value).isEqualTo(BottomDrawerState.Closed)
+        }
+    }
+
+    private fun GestureScope.sendSwipeUpBy(offset: Float) {
+        sendSwipe(center, center.copy(y = center.y - offset))
+    }
+
+    private fun GestureScope.sendSwipeDownBy(offset: Float) {
+        sendSwipe(center, center.copy(y = center.y + offset))
+    }
 }
diff --git a/ui/ui-material/src/main/java/androidx/ui/material/Drawer.kt b/ui/ui-material/src/main/java/androidx/ui/material/Drawer.kt
index 2f4b01c..7b9d87e 100644
--- a/ui/ui-material/src/main/java/androidx/ui/material/Drawer.kt
+++ b/ui/ui-material/src/main/java/androidx/ui/material/Drawer.kt
@@ -35,13 +35,14 @@
 import androidx.ui.layout.offsetPx
 import androidx.ui.layout.padding
 import androidx.ui.layout.preferredSizeIn
+import androidx.ui.material.internal.fixedThresholds
 import androidx.ui.material.internal.stateDraggable
 import androidx.ui.unit.Dp
 import androidx.ui.unit.dp
 import androidx.ui.util.lerp
 
 /**
- * Possible states of the drawer
+ * Possible states of the [ModalDrawerLayout]
  */
 enum class DrawerState {
     /**
@@ -51,8 +52,25 @@
     /**
      * Constant to indicate the state of the drawer when it's opened
      */
+    Opened
+}
+
+/**
+ * Possible states of the [BottomDrawerLayout]
+ */
+enum class BottomDrawerState {
+    /**
+     * Constant to indicate the state of the bottom drawer when it's closed
+     */
+    Closed,
+    /**
+     * Constant to indicate the state of the bottom drawer when it's opened
+     */
     Opened,
-    // Expanded
+    /**
+     * Constant to indicate the state of the bottom drawer when it's fully expanded
+     */
+    Expanded
 }
 
 /**
@@ -117,9 +135,11 @@
             Stack {
                 bodyContent()
             }
-            Scrim(drawerState, onStateChange, fraction = {
-                calculateFraction(minValue, maxValue, drawerPosition.value)
-            })
+            Scrim(
+                opened = drawerState == DrawerState.Opened,
+                 onStateChange(DrawerState.Closed) },
+                fraction = { calculateFraction(minValue, maxValue, drawerPosition.value) }
+            )
             DrawerContent(
                 drawerPosition, dpConstraints, drawerShape, drawerElevation, drawerContent
             )
@@ -155,8 +175,8 @@
  */
 @Composable
 fun BottomDrawerLayout(
-    drawerState: DrawerState,
-    onStateChange: (DrawerState) -> Unit,
+    drawerState: BottomDrawerState,
+    onStateChange: (BottomDrawerState) -> Unit,
     gesturesEnabled: Boolean = true,
     drawerShape: Shape = MaterialTheme.shapes.large,
     drawerElevation: Dp = DrawerConstants.DefaultElevation,
@@ -183,20 +203,25 @@
         )
         val anchors =
             if (isLandscape) {
-                listOf(maxValue to DrawerState.Closed, minValue to DrawerState.Opened)
+                listOf(
+                    maxValue to BottomDrawerState.Closed,
+                    minValue to BottomDrawerState.Opened
+                )
             } else {
                 listOf(
-                    maxValue to DrawerState.Closed,
-                    openedValue to DrawerState.Opened,
-                    minValue to DrawerState.Opened
+                    maxValue to BottomDrawerState.Closed,
+                    openedValue to BottomDrawerState.Opened,
+                    minValue to BottomDrawerState.Expanded
                 )
             }
         val drawerPosition = state { maxValue }
+        val offset = with(DensityAmbient.current) { BottomDrawerThreshold.toPx() }
         Stack(
             Modifier.stateDraggable(
                 state = drawerState,
                 >
                 anchorsToState = anchors,
+                thresholds = fixedThresholds(offset),
                 animationSpec = AnimationSpec,
                 dragDirection = DragDirection.Vertical,
                 minValue = minValue,
@@ -208,10 +233,14 @@
             Stack {
                 bodyContent()
             }
-            Scrim(drawerState, onStateChange, fraction = {
-                // as we scroll "from height to 0" , need to reverse fraction
-                1 - calculateFraction(openedValue, maxValue, drawerPosition.value)
-            })
+            Scrim(
+                opened = drawerState == BottomDrawerState.Opened,
+                 onStateChange(BottomDrawerState.Closed) },
+                fraction = {
+                    // as we scroll "from height to 0" , need to reverse fraction
+                    1 - calculateFraction(openedValue, maxValue, drawerPosition.value)
+                }
+            )
             BottomDrawerContent(
                 drawerPosition, dpConstraints, drawerShape, drawerElevation, drawerContent
             )
@@ -275,13 +304,13 @@
 
 @Composable
 private fun Scrim(
-    state: DrawerState,
-    onStateChange: (DrawerState) -> Unit,
+    opened: Boolean,
+    onClose: () -> Unit,
     fraction: () -> Float
 ) {
     val color = MaterialTheme.colors.onSurface
-    val dismissDrawer = if (state == DrawerState.Opened) {
-        Modifier.tapGestureFilter { _ -> onStateChange(DrawerState.Closed) }
+    val dismissDrawer = if (opened) {
+        Modifier.tapGestureFilter { onClose() }
     } else {
         Modifier
     }
@@ -303,3 +332,4 @@
 private val AnimationSpec = SpringSpec<Float>(stiffness = DrawerStiffness)
 
 internal const val BottomDrawerOpenFraction = 0.5f
+internal val BottomDrawerThreshold = 56.dp
diff --git a/ui/ui-material/src/main/java/androidx/ui/material/internal/StateDraggable.kt b/ui/ui-material/src/main/java/androidx/ui/material/internal/StateDraggable.kt
index ebe8b9f..8f6f731 100644
--- a/ui/ui-material/src/main/java/androidx/ui/material/internal/StateDraggable.kt
+++ b/ui/ui-material/src/main/java/androidx/ui/material/internal/StateDraggable.kt
@@ -20,7 +20,10 @@
 import androidx.animation.AnimationClockObservable
 import androidx.animation.AnimationEndReason
 import androidx.animation.AnimationSpec
+import androidx.animation.ExponentialDecay
 import androidx.animation.Spring
+import androidx.animation.TargetAnimation
+import androidx.annotation.FloatRange
 import androidx.compose.onCommit
 import androidx.compose.remember
 import androidx.compose.state
@@ -29,11 +32,13 @@
 import androidx.ui.core.Modifier
 import androidx.ui.core.composed
 import androidx.ui.foundation.InteractionState
-import androidx.ui.foundation.animation.AnchorsFlingConfig
+import androidx.ui.foundation.animation.FlingConfig
 import androidx.ui.foundation.animation.fling
 import androidx.ui.foundation.gestures.DragDirection
 import androidx.ui.foundation.gestures.draggable
 import androidx.ui.util.fastFirstOrNull
+import androidx.ui.util.lerp
+import kotlin.math.sign
 
 /**
  * Enable automatic drag and animation between predefined states.
@@ -57,6 +62,9 @@
  * @param animationSpec animation which will be used for animations
  * @param dragDirection direction in which drag should be happening.
  * Either [DragDirection.Vertical] or [DragDirection.Horizontal]
+ * @param thresholds the thresholds between anchors that determine which anchor to fling to when
+ * dragging stops, represented as a lambda that takes a pair of anchors and returns a value
+ * between them (note that the order of the anchors matters as it indicates the drag direction)
  * @param enabled whether or not this Draggable is enabled and should consume events
  * @param minValue lower bound for draggable value in this component
  * @param maxValue upper bound for draggable value in this component
@@ -69,6 +77,7 @@
     anchorsToState: List<Pair<Float, T>>,
     animationSpec: AnimationSpec<Float>,
     dragDirection: DragDirection,
+    thresholds: (Float, Float) -> Float = fractionalThresholds(0.5f),
     enabled: Boolean = true,
     minValue: Float = Float.MIN_VALUE,
     maxValue: Float = Float.MAX_VALUE,
@@ -79,8 +88,9 @@
 
     val anchors = remember(anchorsToState) { anchorsToState.map { it.first } }
     val currentValue = anchorsToState.fastFirstOrNull { it.second == state }!!.first
-    val flingConfig =
-        AnchorsFlingConfig(anchors, animationSpec,  reason, finalValue, _ ->
+    val flingConfig = FlingConfig(
+        decayAnimation = ExponentialDecay(),
+         reason, finalValue, _ ->
             if (reason != AnimationEndReason.Interrupted) {
                 val newState = anchorsToState.firstOrNull { it.first == finalValue }?.second
                 if (newState != null && newState != state) {
@@ -88,7 +98,41 @@
                     forceAnimationCheck.value = !forceAnimationCheck.value
                 }
             }
-        })
+        },
+        adjustTarget = { target ->
+            // Find the two anchors the target lies between.
+            val a = anchors.filter { it <= target }.max()
+            val b = anchors.filter { it >= target }.min()
+            // Compute which anchor to fling to.
+            val adjusted: Float =
+                if (a == null && b == null) {
+                    // There are no anchors, so return the target unchanged.
+                    target
+                } else if (a == null) {
+                    // The target lies below the anchors, so return the first anchor (b).
+                    b!!
+                } else if (b == null) {
+                    // The target lies above the anchors, so return the last anchor (b).
+                    a
+                } else if (a == b) {
+                    // The target is equal to one of the anchors, so return the target unchanged.
+                    target
+                } else {
+                    // The target lies strictly between the two anchors a and b.
+                    // Compute the threshold between a and b based on the drag direction.
+                    val threshold = if (currentValue <= a) {
+                        thresholds(a, b)
+                    } else {
+                        thresholds(b, a)
+                    }
+                    require(threshold >= a && threshold <= b) {
+                        "Invalid threshold $threshold between anchors $a and $b."
+                    }
+                    if (target < threshold) a else b
+                }
+            TargetAnimation(adjusted, animationSpec)
+        }
+    )
     val clock = AnimationClockAmbient.current.asDisposableClock()
     val position = remember(clock) {
         onNewValue(currentValue)
@@ -116,6 +160,19 @@
     )
 }
 
+/**
+ * Fixed anchors thresholds. Each threshold will be at an [offset] away from the first anchor.
+ */
+internal fun fixedThresholds(offset: Float): (Float, Float) -> Float =
+    { fromAnchor, toAnchor -> fromAnchor + offset * sign(toAnchor - fromAnchor) }
+
+/**
+ * Fractional thresholds. Each threshold will be at a [fraction] of the way between the two anchors.
+ */
+internal fun fractionalThresholds(
+    @FloatRange(from = 0.0, to = 1.0) fraction: Float
+): (Float, Float) -> Float = { fromAnchor, toAnchor -> lerp(fromAnchor, toAnchor, fraction) }
+
 private class NotificationBasedAnimatedFloat(
     initial: Float,
     clock: AnimationClockObservable,