Skip to content
Snippets Groups Projects
Commit 7fd398fc authored by Jordan Demeulenaere's avatar Jordan Demeulenaere Committed by Android (Google) Code Review
Browse files

Merge changes from topic "stl-dynamic-distance" into main

* changes:
  Move UserActionDistance to TransitionSpec
  Make it possible to compute the swipe distance lazily
parents 0491bba4 3f9cba65
No related branches found
No related tags found
No related merge requests found
Showing
with 290 additions and 93 deletions
......@@ -16,9 +16,6 @@
package com.android.systemui.scene.ui.composable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import com.android.compose.animation.scene.Back
import com.android.compose.animation.scene.Edge as ComposeAwareEdge
import com.android.compose.animation.scene.SceneKey as ComposeAwareSceneKey
......@@ -26,14 +23,12 @@ import com.android.compose.animation.scene.Swipe
import com.android.compose.animation.scene.SwipeDirection
import com.android.compose.animation.scene.TransitionKey as ComposeAwareTransitionKey
import com.android.compose.animation.scene.UserAction as ComposeAwareUserAction
import com.android.compose.animation.scene.UserActionDistance as ComposeAwareUserActionDistance
import com.android.compose.animation.scene.UserActionResult as ComposeAwareUserActionResult
import com.android.systemui.scene.shared.model.Direction
import com.android.systemui.scene.shared.model.Edge
import com.android.systemui.scene.shared.model.SceneKey
import com.android.systemui.scene.shared.model.TransitionKey
import com.android.systemui.scene.shared.model.UserAction
import com.android.systemui.scene.shared.model.UserActionDistance
import com.android.systemui.scene.shared.model.UserActionResult
// TODO(b/293899074): remove this file once we can use the types from SceneTransitionLayout.
......@@ -82,22 +77,5 @@ fun UserActionResult.asComposeAware(): ComposeAwareUserActionResult {
return ComposeAwareUserActionResult(
toScene = composeUnaware.toScene.asComposeAware(),
transitionKey = composeUnaware.transitionKey?.asComposeAware(),
distance = composeUnaware.distance?.asComposeAware(),
)
}
fun UserActionDistance.asComposeAware(): ComposeAwareUserActionDistance {
val composeUnware = this
return object : ComposeAwareUserActionDistance {
override fun Density.absoluteDistance(
fromSceneSize: IntSize,
orientation: Orientation,
): Float {
return composeUnware.absoluteDistance(
fromSceneWidth = fromSceneSize.width,
fromSceneHeight = fromSceneSize.height,
isHorizontal = orientation == Orientation.Horizontal,
)
}
}
}
......@@ -27,7 +27,6 @@ import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.round
......@@ -56,14 +55,17 @@ internal class SceneGestureHandler(
if (isDrivingTransition || force) {
layoutState.startTransition(newTransition, newTransition.key)
// Initialize SwipeTransition.swipeSpec. Note that this must be called right after
// layoutState.startTransition() is called, because it computes the
// layoutState.transformationSpec().
// Initialize SwipeTransition.transformationSpec and .swipeSpec. Note that this must be
// called right after layoutState.startTransition() is called, because it computes the
// current layoutState.transformationSpec().
val transformationSpec = layoutState.transformationSpec
newTransition.transformationSpec = transformationSpec
newTransition.swipeSpec =
layoutState.transformationSpec.swipeSpec ?: layoutState.transitions.defaultSwipeSpec
transformationSpec.swipeSpec ?: layoutState.transitions.defaultSwipeSpec
} else {
// We were not driving the transition and we don't force the update, so the spec won't
// be used and it doesn't matter which one we set here.
// We were not driving the transition and we don't force the update, so the specs won't
// be used and it doesn't matter which ones we set here.
newTransition.transformationSpec = TransformationSpec.Empty
newTransition.swipeSpec = SceneTransitions.DefaultSwipeSpec
}
......@@ -285,16 +287,21 @@ internal class SceneGestureHandler(
): Pair<Scene, Float> {
val toScene = swipeTransition._toScene
val fromScene = swipeTransition._fromScene
val absoluteDistance = swipeTransition.distance.absoluteValue
val distance = swipeTransition.distance()
// If the swipe was not committed, don't do anything.
if (swipeTransition._currentScene != toScene) {
// If the swipe was not committed or if the swipe distance is not computed yet, don't do
// anything.
if (
swipeTransition._currentScene != toScene ||
distance == SwipeTransition.DistanceUnspecified
) {
return fromScene to 0f
}
// If the offset is past the distance then let's change fromScene so that the user can swipe
// to the next screen or go back to the previous one.
val offset = swipeTransition.dragOffset
val absoluteDistance = distance.absoluteValue
return if (offset <= -absoluteDistance && swipes!!.upOrLeftResult?.toScene == toScene.key) {
toScene to absoluteDistance
} else if (
......@@ -347,16 +354,17 @@ internal class SceneGestureHandler(
// Compute the destination scene (and therefore offset) to settle in.
val offset = swipeTransition.dragOffset
val distance = swipeTransition.distance
val distance = swipeTransition.distance()
var targetScene: Scene
var targetOffset: Float
if (
shouldCommitSwipe(
offset,
distance,
velocity,
wasCommitted = swipeTransition._currentScene == toScene,
)
distance != SwipeTransition.DistanceUnspecified &&
shouldCommitSwipe(
offset,
distance,
velocity,
wasCommitted = swipeTransition._currentScene == toScene,
)
) {
targetScene = toScene
targetOffset = distance
......@@ -372,7 +380,15 @@ internal class SceneGestureHandler(
// We wanted to change to a new scene but we are not allowed to, so we animate back
// to the current scene.
targetScene = swipeTransition._currentScene
targetOffset = if (targetScene == fromScene) 0f else distance
targetOffset =
if (targetScene == fromScene) {
0f
} else {
check(distance != SwipeTransition.DistanceUnspecified) {
"distance is equal to ${SwipeTransition.DistanceUnspecified}"
}
distance
}
}
animateTo(targetScene = targetScene, targetOffset = targetOffset)
......@@ -459,22 +475,20 @@ private fun SwipeTransition(
): SwipeTransition {
val upOrLeftResult = swipes.upOrLeftResult
val downOrRightResult = swipes.downOrRightResult
val userActionDistance = result.distance ?: DefaultSwipeDistance
val absoluteDistance =
with(userActionDistance) {
layoutImpl.density.absoluteDistance(fromScene.targetSize, orientation)
val isUpOrLeft =
when (result) {
upOrLeftResult -> true
downOrRightResult -> false
else -> error("Unknown result $result ($upOrLeftResult $downOrRightResult)")
}
return SwipeTransition(
key = result.transitionKey,
_fromScene = fromScene,
_toScene = layoutImpl.scene(result.toScene),
distance =
when (result) {
upOrLeftResult -> -absoluteDistance
downOrRightResult -> absoluteDistance
else -> error("Unknown result $result ($upOrLeftResult $downOrRightResult)")
},
userActionDistanceScope = layoutImpl.userActionDistanceScope,
orientation = orientation,
isUpOrLeft = isUpOrLeft,
)
}
......@@ -482,11 +496,9 @@ private class SwipeTransition(
val key: TransitionKey?,
val _fromScene: Scene,
val _toScene: Scene,
/**
* The signed distance between [fromScene] and [toScene]. It is negative if [fromScene] is above
* or to the left of [toScene]
*/
val distance: Float,
private val userActionDistanceScope: UserActionDistanceScope,
private val orientation: Orientation,
private val isUpOrLeft: Boolean,
) : TransitionState.Transition(_fromScene.key, _toScene.key) {
var _currentScene by mutableStateOf(_fromScene)
override val currentScene: SceneKey
......@@ -494,7 +506,16 @@ private class SwipeTransition(
override val progress: Float
get() {
// Important: If we are going to return early because distance is equal to 0, we should
// still make sure we read the offset before returning so that the calling code still
// subscribes to the offset value.
val offset = if (isAnimatingOffset) offsetAnimatable.value else dragOffset
val distance = distance()
if (distance == DistanceUnspecified) {
return 0f
}
return offset / distance
}
......@@ -518,9 +539,50 @@ private class SwipeTransition(
/** Job to check that there is at most one offset animation in progress. */
private var offsetAnimationJob: Job? = null
/**
* The [TransformationSpecImpl] associated to this transition.
*
* Note: This is lateinit because this [SwipeTransition] is needed by
* [BaseSceneTransitionLayoutState] to compute the [TransitionSpec], and it will be set right
* after [BaseSceneTransitionLayoutState.startTransition] is called with this transition.
*/
lateinit var transformationSpec: TransformationSpecImpl
/** The spec to use when animating this transition to either [fromScene] or [toScene]. */
lateinit var swipeSpec: SpringSpec<Float>
private var lastDistance = DistanceUnspecified
/**
* The signed distance between [fromScene] and [toScene]. It is negative if [fromScene] is above
* or to the left of [toScene].
*
* Note that this distance can be equal to [DistanceUnspecified] during the first frame of a
* transition when the distance depends on the size or position of an element that is composed
* in the scene we are going to.
*/
fun distance(): Float {
if (lastDistance != DistanceUnspecified) {
return lastDistance
}
val absoluteDistance =
with(transformationSpec.distance ?: DefaultSwipeDistance) {
userActionDistanceScope.absoluteDistance(
_fromScene.targetSize,
orientation,
)
}
if (absoluteDistance <= 0f) {
return DistanceUnspecified
}
val distance = if (isUpOrLeft) -absoluteDistance else absoluteDistance
lastDistance = distance
return distance
}
/** Ends any previous [offsetAnimationJob] and runs the new [job]. */
private fun startOffsetAnimation(job: () -> Job) {
cancelOffsetAnimation()
......@@ -563,6 +625,7 @@ private class SwipeTransition(
}
isAnimatingOffset = true
val animationSpec = transformationSpec
offsetAnimatable.animateTo(
targetValue = targetOffset,
animationSpec = swipeSpec,
......@@ -571,10 +634,14 @@ private class SwipeTransition(
finishOffsetAnimation()
}
companion object {
const val DistanceUnspecified = 0f
}
}
private object DefaultSwipeDistance : UserActionDistance {
override fun Density.absoluteDistance(
override fun UserActionDistanceScope.absoluteDistance(
fromSceneSize: IntSize,
orientation: Orientation,
): Float {
......
......@@ -25,6 +25,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
......@@ -394,36 +395,52 @@ class UserActionResult(
/** The scene we should be transitioning to during the [UserAction]. */
val toScene: SceneKey,
/**
* The distance the action takes to animate from 0% to 100%.
*
* If `null`, a default distance will be used that depends on the [UserAction] performed.
*/
val distance: UserActionDistance? = null,
/** The key of the transition that should be used. */
val transitionKey: TransitionKey? = null,
) {
constructor(
toScene: SceneKey,
distance: Dp,
transitionKey: TransitionKey? = null,
) : this(toScene, FixedDistance(distance), transitionKey)
}
)
interface UserActionDistance {
/**
* Return the **absolute** distance of the user action given the size of the scene we are
* animating from and the [orientation].
*
* Note: This function will be called for each drag event until it returns a value > 0f. This
* for instance allows you to return 0f or a negative value until the first layout pass of a
* scene, so that you can use the size and position of elements in the scene we are
* transitioning to when computing this absolute distance.
*/
fun Density.absoluteDistance(fromSceneSize: IntSize, orientation: Orientation): Float
fun UserActionDistanceScope.absoluteDistance(
fromSceneSize: IntSize,
orientation: Orientation
): Float
}
interface UserActionDistanceScope : Density {
/**
* Return the *target* size of [this] element in the given [scene], i.e. the size of the element
* when idle, or `null` if the element is not composed and measured in that scene (yet).
*/
fun ElementKey.targetSize(scene: SceneKey): IntSize?
/**
* Return the *target* offset of [this] element in the given [scene], i.e. the size of the
* element when idle, or `null` if the element is not composed and placed in that scene (yet).
*/
fun ElementKey.targetOffset(scene: SceneKey): Offset?
/**
* Return the *target* size of [this] scene, i.e. the size of the scene when idle, or `null` if
* the scene was never composed.
*/
fun SceneKey.targetSize(): IntSize?
}
/** The user action has a fixed [absoluteDistance]. */
private class FixedDistance(private val distance: Dp) : UserActionDistance {
override fun Density.absoluteDistance(fromSceneSize: IntSize, orientation: Orientation): Float {
return distance.toPx()
}
class FixedDistance(private val distance: Dp) : UserActionDistance {
override fun UserActionDistanceScope.absoluteDistance(
fromSceneSize: IntSize,
orientation: Orientation,
): Float = distance.toPx()
}
/**
......
......@@ -96,9 +96,18 @@ internal class SceneTransitionLayoutImpl(
?: mutableMapOf<ValueKey, MutableMap<ElementKey?, SnapshotStateMap<SceneKey, *>>>()
.also { _sharedValues = it }
// TODO(b/317958526): Lazily allocate scene gesture handlers the first time they are needed.
private val horizontalGestureHandler: SceneGestureHandler
private val verticalGestureHandler: SceneGestureHandler
private var _userActionDistanceScope: UserActionDistanceScope? = null
internal val userActionDistanceScope: UserActionDistanceScope
get() =
_userActionDistanceScope
?: UserActionDistanceScopeImpl(layoutImpl = this).also {
_userActionDistanceScope = it
}
init {
updateScenes(builder)
......
......@@ -163,6 +163,14 @@ interface TransformationSpec {
*/
val swipeSpec: SpringSpec<Float>?
/**
* The distance it takes for this transition to animate from 0% to 100% when it is driven by a
* [UserAction].
*
* If `null`, a default distance will be used that depends on the [UserAction] performed.
*/
val distance: UserActionDistance?
/** The list of [Transformation] applied to elements during this transition. */
val transformations: List<Transformation>
......@@ -171,6 +179,7 @@ interface TransformationSpec {
TransformationSpecImpl(
progressSpec = snap(),
swipeSpec = null,
distance = null,
transformations = emptyList(),
)
internal val EmptyProvider = { Empty }
......@@ -193,6 +202,7 @@ internal class TransitionSpecImpl(
TransformationSpecImpl(
progressSpec = reverse.progressSpec,
swipeSpec = reverse.swipeSpec,
distance = reverse.distance,
transformations = reverse.transformations.map { it.reversed() }
)
}
......@@ -209,6 +219,7 @@ internal class TransitionSpecImpl(
internal class TransformationSpecImpl(
override val progressSpec: AnimationSpec<Float>,
override val swipeSpec: SpringSpec<Float>?,
override val distance: UserActionDistance?,
override val transformations: List<Transformation>,
) : TransformationSpec {
private val cache = mutableMapOf<ElementKey, MutableMap<SceneKey, ElementTransformations>>()
......
......@@ -90,6 +90,14 @@ interface TransitionBuilder : PropertyTransformationBuilder {
*/
var swipeSpec: SpringSpec<Float>?
/**
* The distance it takes for this transition to animate from 0% to 100% when it is driven by a
* [UserAction].
*
* If `null`, a default distance will be used that depends on the [UserAction] performed.
*/
var distance: UserActionDistance?
/**
* Define a progress-based range for the transformations inside [builder].
*
......
......@@ -77,6 +77,7 @@ private class SceneTransitionsBuilderImpl : SceneTransitionsBuilder {
return TransformationSpecImpl(
progressSpec = impl.spec,
swipeSpec = impl.swipeSpec,
distance = impl.distance,
transformations = impl.transformations,
)
}
......@@ -91,6 +92,7 @@ internal class TransitionBuilderImpl : TransitionBuilder {
val transformations = mutableListOf<Transformation>()
override var spec: AnimationSpec<Float> = spring(stiffness = Spring.StiffnessLow)
override var swipeSpec: SpringSpec<Float>? = null
override var distance: UserActionDistance? = null
private var range: TransformationRange? = null
private var reversed = false
......
/*
* Copyright (C) 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 com.android.compose.animation.scene
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntSize
internal class UserActionDistanceScopeImpl(
private val layoutImpl: SceneTransitionLayoutImpl,
) : UserActionDistanceScope {
override val density: Float
get() = layoutImpl.density.density
override val fontScale: Float
get() = layoutImpl.density.fontScale
override fun ElementKey.targetSize(scene: SceneKey): IntSize? {
return layoutImpl.elements[this]?.sceneStates?.get(scene)?.targetSize.takeIf {
it != Element.SizeUnspecified
}
}
override fun ElementKey.targetOffset(scene: SceneKey): Offset? {
return layoutImpl.elements[this]?.sceneStates?.get(scene)?.targetOffset.takeIf {
it != Offset.Unspecified
}
}
override fun SceneKey.targetSize(): IntSize? {
return layoutImpl.scenes[this]?.targetSize.takeIf { it != IntSize.Zero }
}
}
......@@ -16,9 +16,11 @@
package com.android.compose.animation.scene
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
......@@ -33,6 +35,7 @@ import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeWithVelocity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
......@@ -61,8 +64,10 @@ class SwipeToSceneTest {
@get:Rule val rule = createComposeRule()
private fun layoutState(initialScene: SceneKey = TestScenes.SceneA) =
MutableSceneTransitionLayoutState(initialScene, EmptyTestTransitions)
private fun layoutState(
initialScene: SceneKey = TestScenes.SceneA,
transitions: SceneTransitions = EmptyTestTransitions,
) = MutableSceneTransitionLayoutState(initialScene, transitions)
/** The content under test. */
@Composable
......@@ -370,8 +375,16 @@ class SwipeToSceneTest {
// detected as a drag event.
var touchSlop = 0f
val layoutState = layoutState()
val verticalSwipeDistance = 50.dp
val layoutState =
layoutState(
transitions =
transitions {
from(TestScenes.SceneA, to = TestScenes.SceneB) {
distance = FixedDistance(verticalSwipeDistance)
}
}
)
assertThat(verticalSwipeDistance).isNotEqualTo(LayoutHeight)
rule.setContent {
......@@ -383,14 +396,7 @@ class SwipeToSceneTest {
) {
scene(
TestScenes.SceneA,
userActions =
mapOf(
Swipe.Down to
UserActionResult(
toScene = TestScenes.SceneB,
distance = verticalSwipeDistance,
)
),
userActions = mapOf(Swipe.Down to TestScenes.SceneB),
) {
Spacer(Modifier.fillMaxSize())
}
......@@ -548,4 +554,64 @@ class SwipeToSceneTest {
assertThat(state.isTransitioning(from = TestScenes.SceneA, to = TestScenes.SceneB)).isTrue()
assertThat(state.transformationSpec.transformations).hasSize(2)
}
@Test
fun dynamicSwipeDistance() {
val swipeDistance =
object : UserActionDistance {
override fun UserActionDistanceScope.absoluteDistance(
fromSceneSize: IntSize,
orientation: Orientation,
): Float {
// Foo is going to have a vertical offset of 50dp. Let's make the swipe distance
// the difference between the bottom of the scene and the bottom of the element,
// so that we use the offset and size of the element as well as the size of the
// scene.
val fooSize = TestElements.Foo.targetSize(TestScenes.SceneB) ?: return 0f
val fooOffset = TestElements.Foo.targetOffset(TestScenes.SceneB) ?: return 0f
val sceneSize = TestScenes.SceneB.targetSize() ?: return 0f
return sceneSize.height - fooOffset.y - fooSize.height
}
}
val state =
MutableSceneTransitionLayoutState(
TestScenes.SceneA,
transitions {
from(TestScenes.SceneA, to = TestScenes.SceneB) { distance = swipeDistance }
}
)
val layoutSize = 200.dp
val fooYOffset = 50.dp
val fooSize = 25.dp
var touchSlop = 0f
rule.setContent {
touchSlop = LocalViewConfiguration.current.touchSlop
SceneTransitionLayout(state, Modifier.size(layoutSize)) {
scene(TestScenes.SceneA, userActions = mapOf(Swipe.Up to TestScenes.SceneB)) {
Box(Modifier.fillMaxSize())
}
scene(TestScenes.SceneB) {
Box(Modifier.fillMaxSize()) {
Box(Modifier.offset(y = fooYOffset).element(TestElements.Foo).size(fooSize))
}
}
}
}
// Swipe up by half the expected distance to get to 50% progress.
val expectedDistance = layoutSize - fooYOffset - fooSize
rule.onRoot().performTouchInput {
val middle = (layoutSize / 2).toPx()
down(Offset(middle, middle))
moveBy(Offset(0f, -touchSlop - (expectedDistance / 2f).toPx()), delayMillis = 1_000)
}
rule.waitForIdle()
assertThat(state.isTransitioning(from = TestScenes.SceneA, to = TestScenes.SceneB)).isTrue()
assertThat(state.currentTransition!!.progress).isWithin(0.01f).of(0.5f)
}
}
......@@ -21,13 +21,6 @@ data class UserActionResult(
/** The scene we should be transitioning due to the [UserAction]. */
val toScene: SceneKey,
/**
* The distance the action takes to animate from 0% to 100%.
*
* If `null`, a default distance will be used depending on the [UserAction] performed.
*/
val distance: UserActionDistance? = null,
/**
* The key of the transition that should be used, if a specific one should be used.
*
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment