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,