1 /*
<lambda>null2 * Copyright (C) 2024 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package com.android.compose.animation.scene.reveal
18
19 import androidx.compose.animation.core.AnimationVector1D
20 import androidx.compose.animation.core.DeferredTargetAnimation
21 import androidx.compose.animation.core.ExperimentalAnimatableApi
22 import androidx.compose.animation.core.FiniteAnimationSpec
23 import androidx.compose.animation.core.Spring
24 import androidx.compose.animation.core.VectorConverter
25 import androidx.compose.animation.core.spring
26 import androidx.compose.ui.unit.Dp
27 import androidx.compose.ui.unit.IntSize
28 import androidx.compose.ui.unit.dp
29 import androidx.compose.ui.util.fastCoerceAtLeast
30 import androidx.compose.ui.util.fastCoerceAtMost
31 import com.android.compose.animation.scene.ContentKey
32 import com.android.compose.animation.scene.ElementKey
33 import com.android.compose.animation.scene.OverlayKey
34 import com.android.compose.animation.scene.SceneKey
35 import com.android.compose.animation.scene.TransitionBuilder
36 import com.android.compose.animation.scene.UserActionDistance
37 import com.android.compose.animation.scene.content.state.TransitionState
38 import com.android.compose.animation.scene.transformation.CustomPropertyTransformation
39 import com.android.compose.animation.scene.transformation.PropertyTransformation
40 import com.android.compose.animation.scene.transformation.PropertyTransformationScope
41 import kotlin.math.roundToInt
42 import kotlinx.coroutines.CoroutineScope
43
44 interface ContainerRevealHaptics {
45 /**
46 * Called when the reveal threshold is crossed while the user was dragging on screen.
47 *
48 * Important: This callback is called during layout and its implementation should therefore be
49 * very fast or posted to a different thread.
50 *
51 * @param revealed whether we go from hidden to revealed, i.e. whether the container size is
52 * going to jump from a smaller size to a bigger size.
53 */
54 fun onRevealThresholdCrossed(revealed: Boolean)
55 }
56
57 /** Animate the reveal of [container] by animating its size. */
TransitionBuildernull58 fun TransitionBuilder.verticalContainerReveal(
59 container: ElementKey,
60 haptics: ContainerRevealHaptics,
61 ) {
62 // Make the swipe distance be exactly the target height of the container.
63 // TODO(b/376438969): Make sure that this works correctly when the target size of the element
64 // is changing during the transition (e.g. a notification was added). At the moment, the user
65 // action distance is only called until it returns a value > 0f, which is then cached.
66 distance = UserActionDistance { fromContent, toContent, _ ->
67 val targetSizeInFromContent = container.targetSize(fromContent)
68 val targetSizeInToContent = container.targetSize(toContent)
69 if (targetSizeInFromContent != null && targetSizeInToContent != null) {
70 error(
71 "verticalContainerReveal should not be used with shared elements, but " +
72 "${container.debugName} is in both ${fromContent.debugName} and " +
73 toContent.debugName
74 )
75 }
76
77 (targetSizeInToContent?.height ?: targetSizeInFromContent?.height)?.toFloat() ?: 0f
78 }
79
80 // TODO(b/376438969): Improve the motion of this gesture using Motion Mechanics.
81
82 // The min distance to swipe before triggering the reveal spring.
83 val distanceThreshold = 80.dp
84
85 // The minimum height of the container.
86 val minHeight = 10.dp
87
88 // The amount removed from the container width at 0% progress.
89 val widthDelta = 140.dp
90
91 // The ratio at which the distance is tracked before reaching the threshold, e.g. if the user
92 // drags 60dp then the height will be 60dp * 0.25f = 15dp.
93 val trackingRatio = 0.25f
94
95 // The max progress starting from which the container should always be visible, even if we are
96 // animating the container out. This is used so that we don't immediately fade out the container
97 // when triggering a one-off animation that hides it.
98 val alphaProgressThreshold = 0.05f
99
100 // The spring animating the size of the container.
101 val sizeSpec = spring<Float>(stiffness = 380f, dampingRatio = 0.9f)
102
103 // The spring animating the alpha of the container.
104 val alphaSpec = spring<Float>(stiffness = 1200f, dampingRatio = 0.99f)
105
106 // The spring animating the progress when releasing the finger.
107 swipeSpec =
108 spring(
109 stiffness = Spring.StiffnessMediumLow,
110 dampingRatio = Spring.DampingRatioNoBouncy,
111 visibilityThreshold = 0.5f,
112 )
113
114 // Size transformation.
115 transformation(container) {
116 VerticalContainerRevealSizeTransformation(
117 haptics,
118 distanceThreshold,
119 trackingRatio,
120 minHeight,
121 widthDelta,
122 sizeSpec,
123 )
124 }
125
126 // Alpha transformation.
127 transformation(container) {
128 ContainerRevealAlphaTransformation(alphaSpec, alphaProgressThreshold)
129 }
130 }
131
132 @OptIn(ExperimentalAnimatableApi::class)
133 private class VerticalContainerRevealSizeTransformation(
134 private val haptics: ContainerRevealHaptics,
135 private val distanceThreshold: Dp,
136 private val trackingRatio: Float,
137 private val minHeight: Dp,
138 private val widthDelta: Dp,
139 private val spec: FiniteAnimationSpec<Float>,
140 ) : CustomPropertyTransformation<IntSize> {
141 override val property = PropertyTransformation.Property.Size
142
143 private val widthAnimation = DeferredTargetAnimation(Float.VectorConverter)
144 private val heightAnimation = DeferredTargetAnimation(Float.VectorConverter)
145
146 private var previousHasReachedThreshold: Boolean? = null
147
transformnull148 override fun PropertyTransformationScope.transform(
149 content: ContentKey,
150 element: ElementKey,
151 transition: TransitionState.Transition,
152 transitionScope: CoroutineScope,
153 ): IntSize {
154 // The distance to go to 100%. Note that we don't use
155 // TransitionState.HasOverscrollProperties.absoluteDistance because the transition will not
156 // implement HasOverscrollProperties if the transition is triggered and not gesture based.
157 val idleSize = checkNotNull(element.targetSize(content))
158 val userActionDistance = idleSize.height
159 val progress =
160 when ((transition as? TransitionState.HasOverscrollProperties)?.bouncingContent) {
161 null -> transition.progressTo(content)
162 content -> 1f
163 else -> 0f
164 }
165 val distance = (progress * userActionDistance).fastCoerceAtLeast(0f)
166 val threshold = distanceThreshold.toPx()
167
168 // Width.
169 val widthDelta = widthDelta.toPx()
170 val width =
171 (idleSize.width - widthDelta +
172 animateSize(
173 size = widthDelta,
174 distance = distance,
175 threshold = threshold,
176 transitionScope = transitionScope,
177 animation = widthAnimation,
178 ))
179 .roundToInt()
180
181 // Height.
182 val minHeight = minHeight.toPx()
183 val height =
184 (
185 // 1) The minimum size of the container.
186 minHeight +
187
188 // 2) The animated size between the minimum size and the threshold.
189 animateSize(
190 size = threshold - minHeight,
191 distance = distance,
192 threshold = threshold,
193 transitionScope = transitionScope,
194 animation = heightAnimation,
195 ) +
196
197 // 3) The remaining height after the threshold, tracking the finger.
198 (distance - threshold).fastCoerceAtLeast(0f))
199 .roundToInt()
200 .fastCoerceAtMost(idleSize.height)
201
202 // Haptics.
203 val hasReachedThreshold = distance >= threshold
204 if (
205 previousHasReachedThreshold != null &&
206 hasReachedThreshold != previousHasReachedThreshold &&
207 transition.isUserInputOngoing
208 ) {
209 haptics.onRevealThresholdCrossed(revealed = hasReachedThreshold)
210 }
211 previousHasReachedThreshold = hasReachedThreshold
212
213 return IntSize(width = width, height = height)
214 }
215
216 /**
217 * Animate a size up to [size], so that it is equal to 0f when distance is 0f and equal to
218 * [size] when `distance >= threshold`, taking the [trackingRatio] into account.
219 */
220 @OptIn(ExperimentalAnimatableApi::class)
animateSizenull221 private fun animateSize(
222 size: Float,
223 distance: Float,
224 threshold: Float,
225 transitionScope: CoroutineScope,
226 animation: DeferredTargetAnimation<Float, AnimationVector1D>,
227 ): Float {
228 val trackingSize = distance.fastCoerceAtMost(threshold) / threshold * size * trackingRatio
229 val springTarget =
230 if (distance >= threshold) {
231 size * (1f - trackingRatio)
232 } else {
233 0f
234 }
235 val springSize = animation.updateTarget(springTarget, transitionScope, spec)
236 return trackingSize + springSize
237 }
238 }
239
240 @OptIn(ExperimentalAnimatableApi::class)
241 private class ContainerRevealAlphaTransformation(
242 private val spec: FiniteAnimationSpec<Float>,
243 private val progressThreshold: Float,
244 ) : CustomPropertyTransformation<Float> {
245 override val property = PropertyTransformation.Property.Alpha
246 private val alphaAnimation = DeferredTargetAnimation(Float.VectorConverter)
247
transformnull248 override fun PropertyTransformationScope.transform(
249 content: ContentKey,
250 element: ElementKey,
251 transition: TransitionState.Transition,
252 transitionScope: CoroutineScope,
253 ): Float {
254 return alphaAnimation.updateTarget(targetAlpha(transition, content), transitionScope, spec)
255 }
256
targetAlphanull257 private fun targetAlpha(transition: TransitionState.Transition, content: ContentKey): Float {
258 if (transition.isUserInputOngoing) {
259 if (transition !is TransitionState.HasOverscrollProperties) {
260 error(
261 "Unsupported transition driven by user input but that does not have " +
262 "overscroll properties: $transition"
263 )
264 }
265
266 val bouncingContent = transition.bouncingContent
267 return if (bouncingContent != null) {
268 if (bouncingContent == content) 1f else 0f
269 } else {
270 if (transition.progressTo(content) > 0f) 1f else 0f
271 }
272 }
273
274 // The transition was committed (the user released their finger), so the alpha depends on
275 // whether we are animating towards the content (showing the container) or away from it
276 // (hiding the container).
277 val isShowingContainer =
278 when (content) {
279 is SceneKey -> transition.currentScene == content
280 is OverlayKey -> transition.currentOverlays.contains(content)
281 }
282
283 return if (isShowingContainer || transition.progressTo(content) >= progressThreshold) {
284 1f
285 } else {
286 0f
287 }
288 }
289 }
290