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