diff options
| author | 2024-02-21 09:41:27 +0100 | |
|---|---|---|
| committer | 2024-02-23 16:58:55 +0100 | |
| commit | 9c0fbb1a2ac1ac72a547b4ed99a9e809c30d6c4c (patch) | |
| tree | b57dd7d9f9444d5a7f29a48f46527ae65d5f827c | |
| parent | 7b5f3f42fc682ead3aac9a314c7029e613ade135 (diff) | |
Make it possible to compute the swipe distance lazily
This CL makes it possible to compute the swipe distance of swipes
lazily, so that the distance can depend on the size or position of
elements from the scene we are transitioning to.
The way it works is pretty simple: UserActionDistance.absoluteDistance
can return 0f until the swipe distance can be computed. This CL also
exposes a UserActionDistanceScope to get the target size and offset of
an element inside a scene, which are usually useful to compute swipe
distances that depend on an element target state.
Note that some of the code in this CL is going to be moved in
ag/26316632.
Bug: 308961608
Test: SwipeToSceneTest
Flag: N/A
Change-Id: If70c48de0aba7a793942badcb5a24993277302b1
6 files changed, 230 insertions, 30 deletions
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/ComposeAwareExtensions.kt b/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/ComposeAwareExtensions.kt index a7de1eede1f4..52900e643a1f 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/ComposeAwareExtensions.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/ComposeAwareExtensions.kt @@ -17,7 +17,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 @@ -27,6 +26,7 @@ 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.UserActionDistanceScope 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 @@ -89,7 +89,7 @@ fun UserActionResult.asComposeAware(): ComposeAwareUserActionResult { fun UserActionDistance.asComposeAware(): ComposeAwareUserActionDistance { val composeUnware = this return object : ComposeAwareUserActionDistance { - override fun Density.absoluteDistance( + override fun UserActionDistanceScope.absoluteDistance( fromSceneSize: IntSize, orientation: Orientation, ): Float { diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneGestureHandler.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneGestureHandler.kt index 76e7c95f274a..8edf636a2dcb 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneGestureHandler.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneGestureHandler.kt @@ -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 @@ -285,16 +284,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 +351,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 +377,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) @@ -460,21 +473,42 @@ private fun SwipeTransition( val upOrLeftResult = swipes.upOrLeftResult val downOrRightResult = swipes.downOrRightResult val userActionDistance = result.distance ?: DefaultSwipeDistance - val absoluteDistance = - with(userActionDistance) { - layoutImpl.density.absoluteDistance(fromScene.targetSize, orientation) + + // The absolute distance of the gesture. Note that the UserActionDistance might return 0f or a + // negative value at first if it needs the size or offset of an element that is not composed yet + // when computing the distance. We call UserActionDistance.absoluteDistance() until it returns a + // value different than 0. + var lastAbsoluteDistance = 0f + val absoluteDistance: () -> Float = { + if (lastAbsoluteDistance > 0f) { + lastAbsoluteDistance + } else { + with(userActionDistance) { + layoutImpl.userActionDistanceScope.absoluteDistance( + fromScene.targetSize, + orientation, + ) + } + .also { lastAbsoluteDistance = it } } + } + + // The signed distance of the gesture. + val distance: () -> Float = { + val absoluteDistance = absoluteDistance() + when { + absoluteDistance <= 0f -> SwipeTransition.DistanceUnspecified + result == upOrLeftResult -> -absoluteDistance + result == downOrRightResult -> absoluteDistance + 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)") - }, + distance = distance, ) } @@ -482,11 +516,16 @@ 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] + * 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. */ - val distance: Float, + val distance: () -> Float, ) : TransitionState.Transition(_fromScene.key, _toScene.key) { var _currentScene by mutableStateOf(_fromScene) override val currentScene: SceneKey @@ -494,7 +533,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 } @@ -571,10 +619,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 { diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt index e1f8a0959f6f..11085d9c95e8 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt @@ -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 @@ -415,15 +416,44 @@ 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() - } + override fun UserActionDistanceScope.absoluteDistance( + fromSceneSize: IntSize, + orientation: Orientation, + ): Float = distance.toPx() } /** diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt index 08399ff03f63..039a5b0c9523 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt @@ -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) diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/UserActionDistanceScopeImpl.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/UserActionDistanceScopeImpl.kt new file mode 100644 index 000000000000..228d19f09cff --- /dev/null +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/UserActionDistanceScopeImpl.kt @@ -0,0 +1,46 @@ +/* + * 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 } + } +} diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt index 543ed0482430..44b5d7f3a5fd 100644 --- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt @@ -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 @@ -548,4 +551,64 @@ class SwipeToSceneTest { assertThat(state.isTransitioning(from = TestScenes.SceneA, to = TestScenes.SceneB)).isTrue() assertThat(state.transformationSpec.transformations).hasSize(2) } + + @Test + fun dynamicSwipeDistance() { + val state = MutableSceneTransitionLayoutState(TestScenes.SceneA) + 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 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 + UserActionResult(TestScenes.SceneB, distance = swipeDistance) + ) + ) { + 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) + } } |