diff options
| author | 2024-11-14 10:35:32 +0000 | |
|---|---|---|
| committer | 2024-11-14 10:35:32 +0000 | |
| commit | 63eaa96dbf2d0d25d42f374eeb4a91a26eb75f13 (patch) | |
| tree | 89ffdfed5e63412af9a405479825b98556f8b4b1 | |
| parent | cb9c1fc7f0d65663d10e1b185649695431f47e4c (diff) | |
| parent | 8bbeca4133b9e7ee043a18ee056e3f45311f14ab (diff) | |
Merge "Introduce a simple version of verticalContainerReveal()" into main
| -rw-r--r-- | packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/reveal/ContainerReveal.kt | 289 |
1 files changed, 289 insertions, 0 deletions
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/reveal/ContainerReveal.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/reveal/ContainerReveal.kt new file mode 100644 index 000000000000..bfb5ca733d90 --- /dev/null +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/reveal/ContainerReveal.kt @@ -0,0 +1,289 @@ +/* + * 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.reveal + +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.DeferredTargetAnimation +import androidx.compose.animation.core.ExperimentalAnimatableApi +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.spring +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastCoerceAtLeast +import androidx.compose.ui.util.fastCoerceAtMost +import com.android.compose.animation.scene.ContentKey +import com.android.compose.animation.scene.ElementKey +import com.android.compose.animation.scene.OverlayKey +import com.android.compose.animation.scene.SceneKey +import com.android.compose.animation.scene.TransitionBuilder +import com.android.compose.animation.scene.UserActionDistance +import com.android.compose.animation.scene.content.state.TransitionState +import com.android.compose.animation.scene.transformation.CustomPropertyTransformation +import com.android.compose.animation.scene.transformation.PropertyTransformation +import com.android.compose.animation.scene.transformation.PropertyTransformationScope +import kotlin.math.roundToInt +import kotlinx.coroutines.CoroutineScope + +interface ContainerRevealHaptics { + /** + * Called when the reveal threshold is crossed while the user was dragging on screen. + * + * Important: This callback is called during layout and its implementation should therefore be + * very fast or posted to a different thread. + * + * @param revealed whether we go from hidden to revealed, i.e. whether the container size is + * going to jump from a smaller size to a bigger size. + */ + fun onRevealThresholdCrossed(revealed: Boolean) +} + +/** Animate the reveal of [container] by animating its size. */ +fun TransitionBuilder.verticalContainerReveal( + container: ElementKey, + haptics: ContainerRevealHaptics, +) { + // Make the swipe distance be exactly the target height of the container. + // TODO(b/376438969): Make sure that this works correctly when the target size of the element + // is changing during the transition (e.g. a notification was added). At the moment, the user + // action distance is only called until it returns a value > 0f, which is then cached. + distance = UserActionDistance { fromContent, toContent, _ -> + val targetSizeInFromContent = container.targetSize(fromContent) + val targetSizeInToContent = container.targetSize(toContent) + if (targetSizeInFromContent != null && targetSizeInToContent != null) { + error( + "verticalContainerReveal should not be used with shared elements, but " + + "${container.debugName} is in both ${fromContent.debugName} and " + + toContent.debugName + ) + } + + (targetSizeInToContent?.height ?: targetSizeInFromContent?.height)?.toFloat() ?: 0f + } + + // TODO(b/376438969): Improve the motion of this gesture using Motion Mechanics. + + // The min distance to swipe before triggering the reveal spring. + val distanceThreshold = 80.dp + + // The minimum height of the container. + val minHeight = 10.dp + + // The amount removed from the container width at 0% progress. + val widthDelta = 140.dp + + // The ratio at which the distance is tracked before reaching the threshold, e.g. if the user + // drags 60dp then the height will be 60dp * 0.25f = 15dp. + val trackingRatio = 0.25f + + // The max progress starting from which the container should always be visible, even if we are + // animating the container out. This is used so that we don't immediately fade out the container + // when triggering a one-off animation that hides it. + val alphaProgressThreshold = 0.05f + + // The spring animating the size of the container. + val sizeSpec = spring<Float>(stiffness = 380f, dampingRatio = 0.9f) + + // The spring animating the alpha of the container. + val alphaSpec = spring<Float>(stiffness = 1200f, dampingRatio = 0.99f) + + // The spring animating the progress when releasing the finger. + swipeSpec = + spring( + stiffness = Spring.StiffnessMediumLow, + dampingRatio = Spring.DampingRatioNoBouncy, + visibilityThreshold = 0.5f, + ) + + // Size transformation. + transformation(container) { + VerticalContainerRevealSizeTransformation( + haptics, + distanceThreshold, + trackingRatio, + minHeight, + widthDelta, + sizeSpec, + ) + } + + // Alpha transformation. + transformation(container) { + ContainerRevealAlphaTransformation(alphaSpec, alphaProgressThreshold) + } +} + +@OptIn(ExperimentalAnimatableApi::class) +private class VerticalContainerRevealSizeTransformation( + private val haptics: ContainerRevealHaptics, + private val distanceThreshold: Dp, + private val trackingRatio: Float, + private val minHeight: Dp, + private val widthDelta: Dp, + private val spec: FiniteAnimationSpec<Float>, +) : CustomPropertyTransformation<IntSize> { + override val property = PropertyTransformation.Property.Size + + private val widthAnimation = DeferredTargetAnimation(Float.VectorConverter) + private val heightAnimation = DeferredTargetAnimation(Float.VectorConverter) + + private var previousHasReachedThreshold: Boolean? = null + + override fun PropertyTransformationScope.transform( + content: ContentKey, + element: ElementKey, + transition: TransitionState.Transition, + transitionScope: CoroutineScope, + ): IntSize { + // The distance to go to 100%. Note that we don't use + // TransitionState.HasOverscrollProperties.absoluteDistance because the transition will not + // implement HasOverscrollProperties if the transition is triggered and not gesture based. + val idleSize = checkNotNull(element.targetSize(content)) + val userActionDistance = idleSize.height + val progress = + when ((transition as? TransitionState.HasOverscrollProperties)?.bouncingContent) { + null -> transition.progressTo(content) + content -> 1f + else -> 0f + } + val distance = (progress * userActionDistance).fastCoerceAtLeast(0f) + val threshold = distanceThreshold.toPx() + + // Width. + val widthDelta = widthDelta.toPx() + val width = + (idleSize.width - widthDelta + + animateSize( + size = widthDelta, + distance = distance, + threshold = threshold, + transitionScope = transitionScope, + animation = widthAnimation, + )) + .roundToInt() + + // Height. + val minHeight = minHeight.toPx() + val height = + ( + // 1) The minimum size of the container. + minHeight + + + // 2) The animated size between the minimum size and the threshold. + animateSize( + size = threshold - minHeight, + distance = distance, + threshold = threshold, + transitionScope = transitionScope, + animation = heightAnimation, + ) + + + // 3) The remaining height after the threshold, tracking the finger. + (distance - threshold).fastCoerceAtLeast(0f)) + .roundToInt() + .fastCoerceAtMost(idleSize.height) + + // Haptics. + val hasReachedThreshold = distance >= threshold + if ( + previousHasReachedThreshold != null && + hasReachedThreshold != previousHasReachedThreshold && + transition.isUserInputOngoing + ) { + haptics.onRevealThresholdCrossed(revealed = hasReachedThreshold) + } + previousHasReachedThreshold = hasReachedThreshold + + return IntSize(width = width, height = height) + } + + /** + * Animate a size up to [size], so that it is equal to 0f when distance is 0f and equal to + * [size] when `distance >= threshold`, taking the [trackingRatio] into account. + */ + @OptIn(ExperimentalAnimatableApi::class) + private fun animateSize( + size: Float, + distance: Float, + threshold: Float, + transitionScope: CoroutineScope, + animation: DeferredTargetAnimation<Float, AnimationVector1D>, + ): Float { + val trackingSize = distance.fastCoerceAtMost(threshold) / threshold * size * trackingRatio + val springTarget = + if (distance >= threshold) { + size * (1f - trackingRatio) + } else { + 0f + } + val springSize = animation.updateTarget(springTarget, transitionScope, spec) + return trackingSize + springSize + } +} + +@OptIn(ExperimentalAnimatableApi::class) +private class ContainerRevealAlphaTransformation( + private val spec: FiniteAnimationSpec<Float>, + private val progressThreshold: Float, +) : CustomPropertyTransformation<Float> { + override val property = PropertyTransformation.Property.Alpha + private val alphaAnimation = DeferredTargetAnimation(Float.VectorConverter) + + override fun PropertyTransformationScope.transform( + content: ContentKey, + element: ElementKey, + transition: TransitionState.Transition, + transitionScope: CoroutineScope, + ): Float { + return alphaAnimation.updateTarget(targetAlpha(transition, content), transitionScope, spec) + } + + private fun targetAlpha(transition: TransitionState.Transition, content: ContentKey): Float { + if (transition.isUserInputOngoing) { + if (transition !is TransitionState.HasOverscrollProperties) { + error( + "Unsupported transition driven by user input but that does not have " + + "overscroll properties: $transition" + ) + } + + val bouncingContent = transition.bouncingContent + return if (bouncingContent != null) { + if (bouncingContent == content) 1f else 0f + } else { + if (transition.progressTo(content) > 0f) 1f else 0f + } + } + + // The transition was committed (the user released their finger), so the alpha depends on + // whether we are animating towards the content (showing the container) or away from it + // (hiding the container). + val isShowingContainer = + when (content) { + is SceneKey -> transition.currentScene == content + is OverlayKey -> transition.currentOverlays.contains(content) + } + + return if (isShowingContainer || transition.progressTo(content) >= progressThreshold) { + 1f + } else { + 0f + } + } +} |