Implement settling logic to pane expansion anchor positions
Test: instrumentation tests
Bug: 327637983
Change-Id: I9ff9777409ef7ad5d18d013df4d7ed704eeb0b07
diff --git a/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldScreenshotTest.kt b/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldScreenshotTest.kt
index 4538a66..da49e44 100644
--- a/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldScreenshotTest.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldScreenshotTest.kt
@@ -486,7 +486,7 @@
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
-private fun SampleThreePaneScaffoldWithPaneExpansion(
+internal fun SampleThreePaneScaffoldWithPaneExpansion(
paneExpansionState: PaneExpansionState,
paneExpansionDragHandle: (@Composable (PaneExpansionState) -> Unit)? = null,
) {
@@ -509,6 +509,6 @@
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
-private fun MockDragHandle(state: PaneExpansionState) {
+internal fun MockDragHandle(state: PaneExpansionState) {
PaneExpansionDragHandle(state, MaterialTheme.colorScheme.outline)
}
diff --git a/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldTest.kt b/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldTest.kt
index f738efb..25b7da3 100644
--- a/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldTest.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldTest.kt
@@ -23,13 +23,19 @@
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -164,12 +170,147 @@
rule.onNodeWithTag("SecondaryPane").assertExists()
rule.onNodeWithTag("TertiaryPane").assertDoesNotExist()
}
+
+ @Test
+ fun threePaneScaffold_paneExpansionWithDragHandle_slowDraggingAndSettling() {
+ val mockPaneExpansionState = PaneExpansionState(anchors = MockPaneExpansionAnchors)
+ var mockDraggingPx = 0f
+ var expectedSettledOffsetPx = 0
+ lateinit var scope: CoroutineScope
+
+ rule.setContentWithSimulatedSize(
+ simulatedWidth = 1024.dp,
+ simulatedHeight = 800.dp
+ ) {
+ scope = rememberCoroutineScope()
+ mockDraggingPx = with(LocalDensity.current) { 200.dp.toPx() }
+ expectedSettledOffsetPx = with(LocalDensity.current) {
+ MockPaneExpansionMiddleAnchor.toPx().toInt()
+ }
+ SampleThreePaneScaffoldWithPaneExpansion(mockPaneExpansionState) {
+ MockDragHandle(it)
+ }
+ }
+
+ rule.runOnIdle {
+ mockPaneExpansionState.dispatchRawDelta(mockDraggingPx)
+ scope.launch {
+ mockPaneExpansionState.settleToAnchorIfNeeded(0F)
+ }
+ }
+
+ rule.runOnIdle {
+ assertThat(mockPaneExpansionState.currentMeasuredDraggingOffset).isEqualTo(
+ expectedSettledOffsetPx
+ )
+ }
+ }
+
+ @Test
+ fun threePaneScaffold_paneExpansionWithDragHandle_fastDraggingAndSettling() {
+ val mockPaneExpansionState = PaneExpansionState(anchors = MockPaneExpansionAnchors)
+ var mockDraggingPx = 0f
+ lateinit var scope: CoroutineScope
+
+ rule.setContentWithSimulatedSize(
+ simulatedWidth = 1024.dp,
+ simulatedHeight = 800.dp
+ ) {
+ scope = rememberCoroutineScope()
+ mockDraggingPx = with(LocalDensity.current) { 200.dp.toPx() }
+ SampleThreePaneScaffoldWithPaneExpansion(mockPaneExpansionState) {
+ MockDragHandle(it)
+ }
+ }
+
+ rule.runOnIdle {
+ mockPaneExpansionState.dispatchRawDelta(mockDraggingPx)
+ scope.launch {
+ mockPaneExpansionState.settleToAnchorIfNeeded(400F)
+ }
+ }
+
+ rule.runOnIdle {
+ assertThat(mockPaneExpansionState.currentMeasuredDraggingOffset).isEqualTo(
+ mockPaneExpansionState.maxExpansionWidth
+ )
+ }
+ }
+
+ @Test
+ fun threePaneScaffold_paneExpansionWithDragHandle_draggingAndSettlingCloseToLeftEdge() {
+ val mockPaneExpansionState = PaneExpansionState(anchors = MockPaneExpansionAnchors)
+ var mockDraggingDp = 0f
+ lateinit var scope: CoroutineScope
+
+ rule.setContentWithSimulatedSize(
+ simulatedWidth = 1024.dp,
+ simulatedHeight = 800.dp
+ ) {
+ scope = rememberCoroutineScope()
+ mockDraggingDp = with(LocalDensity.current) { -360.dp.toPx() }
+ SampleThreePaneScaffoldWithPaneExpansion(mockPaneExpansionState) {
+ MockDragHandle(it)
+ }
+ }
+
+ rule.runOnIdle {
+ mockPaneExpansionState.dispatchRawDelta(mockDraggingDp)
+ scope.launch {
+ mockPaneExpansionState.settleToAnchorIfNeeded(-200F)
+ }
+ }
+
+ rule.runOnIdle {
+ assertThat(mockPaneExpansionState.currentMeasuredDraggingOffset).isEqualTo(0)
+ }
+ }
+
+ @Test
+ fun threePaneScaffold_paneExpansionWithDragHandle_draggingAndSettlingCloseToRightEdge() {
+ val mockPaneExpansionState = PaneExpansionState(anchors = MockPaneExpansionAnchors)
+ var mockDraggingDp = 0f
+ lateinit var scope: CoroutineScope
+
+ rule.setContentWithSimulatedSize(
+ simulatedWidth = 1024.dp,
+ simulatedHeight = 800.dp
+ ) {
+ scope = rememberCoroutineScope()
+ mockDraggingDp = with(LocalDensity.current) { 640.dp.toPx() }
+ SampleThreePaneScaffoldWithPaneExpansion(mockPaneExpansionState) {
+ MockDragHandle(it)
+ }
+ }
+
+ rule.runOnIdle {
+ mockPaneExpansionState.dispatchRawDelta(mockDraggingDp)
+ scope.launch {
+ mockPaneExpansionState.settleToAnchorIfNeeded(200F)
+ }
+ }
+
+ rule.runOnIdle {
+ assertThat(mockPaneExpansionState.currentMeasuredDraggingOffset).isEqualTo(
+ mockPaneExpansionState.maxExpansionWidth
+ )
+ }
+ }
}
private val MockScaffoldDirective = PaneScaffoldDirective.Default
internal const val ThreePaneScaffoldTestTag = "SampleThreePaneScaffold"
+private val MockPaneExpansionMiddleAnchor = 400.dp
+
+@OptIn(ExperimentalMaterial3AdaptiveApi::class)
+private val MockPaneExpansionAnchors = listOf(
+ PaneExpansionAnchor(percentage = 0),
+ PaneExpansionAnchor(startOffset = MockPaneExpansionMiddleAnchor),
+ PaneExpansionAnchor(percentage = 100),
+)
+
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
private fun SampleThreePaneScaffold(scaffoldValue: ThreePaneScaffoldValue) {
diff --git a/compose/material3/adaptive/adaptive-layout/src/androidMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionDragHandle.android.kt b/compose/material3/adaptive/adaptive-layout/src/androidMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionDragHandle.android.kt
new file mode 100644
index 0000000..6377185
--- /dev/null
+++ b/compose/material3/adaptive/adaptive-layout/src/androidMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionDragHandle.android.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2024 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 androidx.compose.material3.adaptive.layout
+
+import androidx.compose.foundation.systemGestureExclusion as androidSystemGestureExclusion
+import androidx.compose.ui.Modifier
+
+internal actual fun Modifier.systemGestureExclusion(): Modifier =
+ this.androidSystemGestureExclusion()
diff --git a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionDragHandle.kt b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionDragHandle.kt
index 5cf1465..8382ba4 100644
--- a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionDragHandle.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionDragHandle.kt
@@ -57,4 +57,10 @@
@ExperimentalMaterial3AdaptiveApi
internal fun Modifier.paneExpansionDragHandle(state: PaneExpansionState): Modifier =
- this.draggable(state, Orientation.Horizontal)
+ this.draggable(
+ state = state,
+ orientation = Orientation.Horizontal,
+ onDragStopped = { velocity -> state.settleToAnchorIfNeeded(velocity) }
+ ).systemGestureExclusion()
+
+internal expect fun Modifier.systemGestureExclusion(): Modifier
diff --git a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionState.kt b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionState.kt
index 8ae6844..10b24e4 100644
--- a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionState.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionState.kt
@@ -16,22 +16,35 @@
package androidx.compose.material3.adaptive.layout
+import androidx.annotation.IntRange
+import androidx.annotation.VisibleForTesting
+import androidx.collection.IntList
+import androidx.collection.MutableIntList
+import androidx.collection.emptyIntList
+import androidx.compose.animation.core.animate
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.MutatorMutex
import androidx.compose.foundation.gestures.DragScope
import androidx.compose.foundation.gestures.DraggableState
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
+import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.isSpecified
+import kotlin.math.abs
import kotlinx.coroutines.coroutineScope
@ExperimentalMaterial3AdaptiveApi
@Stable
-internal class PaneExpansionState : DraggableState {
+internal class PaneExpansionState(
+ internal val anchors: List<PaneExpansionAnchor> = emptyList()
+) : DraggableState {
private var firstPaneWidthState by mutableIntStateOf(UnspecifiedWidth)
private var firstPanePercentageState by mutableFloatStateOf(Float.NaN)
private var currentDraggingOffsetState by mutableIntStateOf(UnspecifiedWidth)
@@ -56,29 +69,33 @@
internal var currentDraggingOffset
get() = currentDraggingOffsetState
private set(value) {
+ val coercedValue = value.coerceIn(0, maxExpansionWidth)
if (value == currentDraggingOffsetState) {
return
}
- currentDraggingOffsetState = value
+ currentDraggingOffsetState = coercedValue
+ currentMeasuredDraggingOffset = coercedValue
}
- // Use this field to store the dragging offset decided by measuring instead of dragging to
- // prevent redundant re-composition.
- internal var currentMeasuredDraggingOffset = UnspecifiedWidth
-
internal var isDragging by mutableStateOf(false)
private set
+ internal var isSettling by mutableStateOf(false)
+ private set
+
+ internal val isDraggingOrSettling get() = isDragging || isSettling
+
+ @VisibleForTesting
internal var maxExpansionWidth = 0
- set(value) {
- if (field == value) {
- return
- }
- field = value
- if (firstPaneWidth != UnspecifiedWidth) {
- firstPaneWidth = firstPaneWidth
- }
- }
+ private set
+
+ // Use this field to store the dragging offset decided by measuring instead of dragging to
+ // prevent redundant re-composition.
+ @VisibleForTesting
+ internal var currentMeasuredDraggingOffset = UnspecifiedWidth
+ private set
+
+ private var anchorPositions: IntList = emptyIntList()
private val dragScope: DragScope = object : DragScope {
override fun dragBy(pixels: Float): Unit = dispatchRawDelta(pixels)
@@ -95,9 +112,7 @@
if (currentMeasuredDraggingOffset == UnspecifiedWidth) {
return
}
- currentDraggingOffset =
- (currentMeasuredDraggingOffset + delta).toInt().coerceIn(0, maxExpansionWidth)
- currentMeasuredDraggingOffset = currentDraggingOffset
+ currentDraggingOffset = (currentMeasuredDraggingOffset + delta).toInt()
}
override suspend fun drag(
@@ -109,7 +124,121 @@
isDragging = false
}
+ internal fun onMeasured(measuredWidth: Int, density: Density) {
+ if (measuredWidth == maxExpansionWidth) {
+ return
+ }
+ maxExpansionWidth = measuredWidth
+ if (firstPaneWidth != UnspecifiedWidth) {
+ firstPaneWidth = firstPaneWidth
+ }
+ anchorPositions = anchors.toPositions(measuredWidth, density)
+ }
+
+ internal fun onExpansionOffsetMeasured(measuredOffset: Int) {
+ currentMeasuredDraggingOffset = measuredOffset
+ }
+
+ internal suspend fun settleToAnchorIfNeeded(velocity: Float) {
+ val currentAnchorPositions = anchorPositions
+ if (currentAnchorPositions.isEmpty()) {
+ return
+ }
+ dragMutex.mutate(MutatePriority.PreventUserInput) {
+ isSettling = true
+ // TODO(conradchen): Use the right animation spec here.
+ animate(
+ currentMeasuredDraggingOffset.toFloat(),
+ currentAnchorPositions.getPositionOfTheClosestAnchor(
+ currentMeasuredDraggingOffset,
+ velocity
+ ).toFloat(),
+ velocity,
+ ) { value, _ ->
+ currentDraggingOffset = value.toInt()
+ }
+ isSettling = false
+ }
+ }
+
+ private fun IntList.getPositionOfTheClosestAnchor(
+ currentPosition: Int,
+ velocity: Float
+ ): Int = minBy(
+ when {
+ velocity >= AnchoringVelocityThreshold -> {
+ { anchorPosition: Int ->
+ val delta = anchorPosition - currentPosition
+ if (delta < 0) Int.MAX_VALUE else delta
+ }
+ }
+ velocity <= -AnchoringVelocityThreshold -> {
+ { anchorPosition: Int ->
+ val delta = currentPosition - anchorPosition
+ if (delta < 0) Int.MAX_VALUE else delta
+ }
+ }
+ else -> {
+ { anchorPosition: Int ->
+ abs(currentPosition - anchorPosition)
+ }
+ }
+ }
+ )
+
companion object {
const val UnspecifiedWidth = -1
+ private const val AnchoringVelocityThreshold = 200F
}
}
+
+@ExperimentalMaterial3AdaptiveApi
+@Immutable
+internal class PaneExpansionAnchor private constructor(
+ val percentage: Int,
+ val startOffset: Dp // TODO(conradchen): confirm RTL support
+) {
+ constructor(@IntRange(0, 100) percentage: Int) : this(percentage, Dp.Unspecified)
+
+ constructor(startOffset: Dp) : this(Int.MIN_VALUE, startOffset)
+}
+
+@OptIn(ExperimentalMaterial3AdaptiveApi::class)
+private fun List<PaneExpansionAnchor>.toPositions(
+ maxExpansionWidth: Int,
+ density: Density
+): IntList {
+ val anchors = MutableIntList(size)
+ @Suppress("ListIterator") // Not necessarily a random-accessible list
+ forEach { anchor ->
+ if (anchor.startOffset.isSpecified) {
+ val position = with(density) { anchor.startOffset.toPx() }.toInt().let {
+ if (it < 0) maxExpansionWidth + it else it
+ }
+ if (position in 0..maxExpansionWidth) {
+ anchors.add(position)
+ }
+ } else {
+ anchors.add(maxExpansionWidth * anchor.percentage / 100)
+ }
+ }
+ anchors.sort()
+ return anchors
+}
+
+private fun <T : Comparable<T>> IntList.minBy(selector: (Int) -> T): Int {
+ if (isEmpty()) {
+ throw NoSuchElementException()
+ }
+ var minElem = this[0]
+ var minValue = selector(minElem)
+ for (i in 1 until size) {
+ val elem = this[i]
+ val value = selector(elem)
+ if (minValue > value) {
+ minElem = elem
+ minValue = value
+ }
+ }
+ return minElem
+}
diff --git a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffold.kt b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffold.kt
index 6e92343..096469a 100644
--- a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffold.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffold.kt
@@ -308,7 +308,7 @@
constraints.maxHeight
)
if (!isLookingAhead) {
- paneExpansionState.maxExpansionWidth = outerBounds.width
+ paneExpansionState.onMeasured(outerBounds.width, this@measure)
}
if (!paneExpansionState.isUnspecified() && visiblePanes.size == 2) {
@@ -318,7 +318,7 @@
// Respect the user dragging result if there's any
val halfSpacerSize = verticalSpacerSize / 2
if (paneExpansionState.currentDraggingOffset <= halfSpacerSize) {
- val bounds = if (paneExpansionState.isDragging) {
+ val bounds = if (paneExpansionState.isDraggingOrSettling) {
outerBounds.copy(
left = paneExpansionState.currentDraggingOffset * 2 +
outerBounds.left
@@ -333,7 +333,7 @@
)
} else if (paneExpansionState.currentDraggingOffset >=
outerBounds.width - halfSpacerSize) {
- val bounds = if (paneExpansionState.isDragging) {
+ val bounds = if (paneExpansionState.isDraggingOrSettling) {
outerBounds.copy(
right = paneExpansionState.currentDraggingOffset * 2 -
outerBounds.right
@@ -497,7 +497,7 @@
if (visiblePanes.size == 2 && dragHandleMeasurables.isNotEmpty()) {
val handleOffsetX =
- if (!paneExpansionState.isDragging ||
+ if (!paneExpansionState.isDraggingOrSettling ||
paneExpansionState.currentDraggingOffset ==
PaneExpansionState.UnspecifiedWidth) {
val spacerMiddleOffset = getSpacerMiddleOffsetX(
@@ -505,7 +505,7 @@
visiblePanes[1]
)
if (!isLookingAhead) {
- paneExpansionState.currentMeasuredDraggingOffset = spacerMiddleOffset
+ paneExpansionState.onExpansionOffsetMeasured(spacerMiddleOffset)
}
spacerMiddleOffset
} else {
@@ -519,8 +519,7 @@
handleOffsetX
)
} else if (!isLookingAhead) {
- paneExpansionState.currentMeasuredDraggingOffset =
- PaneExpansionState.UnspecifiedWidth
+ paneExpansionState.onExpansionOffsetMeasured(PaneExpansionState.UnspecifiedWidth)
}
// Place the hidden panes to ensure a proper motion at the AnimatedVisibility,
diff --git a/compose/material3/adaptive/adaptive-layout/src/desktopMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionDragHandle.desktop.kt b/compose/material3/adaptive/adaptive-layout/src/desktopMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionDragHandle.desktop.kt
new file mode 100644
index 0000000..d4a754b
--- /dev/null
+++ b/compose/material3/adaptive/adaptive-layout/src/desktopMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionDragHandle.desktop.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2024 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 androidx.compose.material3.adaptive.layout
+
+import androidx.compose.ui.Modifier
+
+internal actual fun Modifier.systemGestureExclusion(): Modifier = this