1 /*
2  * Copyright 2020 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 androidx.compose.animation
18 
19 import androidx.compose.animation.core.Animatable
20 import androidx.compose.animation.core.AnimationEndReason
21 import androidx.compose.animation.core.AnimationSpec
22 import androidx.compose.animation.core.AnimationVector2D
23 import androidx.compose.animation.core.FiniteAnimationSpec
24 import androidx.compose.animation.core.Spring
25 import androidx.compose.animation.core.VectorConverter
26 import androidx.compose.animation.core.VisibilityThreshold
27 import androidx.compose.animation.core.spring
28 import androidx.compose.runtime.getValue
29 import androidx.compose.runtime.mutableStateOf
30 import androidx.compose.runtime.setValue
31 import androidx.compose.ui.Alignment
32 import androidx.compose.ui.Modifier
33 import androidx.compose.ui.draw.clipToBounds
34 import androidx.compose.ui.layout.IntrinsicMeasurable
35 import androidx.compose.ui.layout.IntrinsicMeasureScope
36 import androidx.compose.ui.layout.LayoutModifier
37 import androidx.compose.ui.layout.Measurable
38 import androidx.compose.ui.layout.MeasureResult
39 import androidx.compose.ui.layout.MeasureScope
40 import androidx.compose.ui.node.LayoutModifierNode
41 import androidx.compose.ui.node.ModifierNodeElement
42 import androidx.compose.ui.platform.InspectorInfo
43 import androidx.compose.ui.unit.Constraints
44 import androidx.compose.ui.unit.IntSize
45 import androidx.compose.ui.unit.constrain
46 import kotlinx.coroutines.launch
47 
48 /**
49  * This modifier animates its own size when its child modifier (or the child composable if it is
50  * already at the tail of the chain) changes size. This allows the parent modifier to observe a
51  * smooth size change, resulting in an overall continuous visual change.
52  *
53  * A [FiniteAnimationSpec] can be optionally specified for the size change animation. By default,
54  * [spring] will be used.
55  *
56  * An optional [finishedListener] can be supplied to get notified when the size change animation is
57  * finished. Since the content size change can be dynamic in many cases, both initial value and
58  * target value (i.e. final size) will be passed to the [finishedListener]. __Note:__ if the
59  * animation is interrupted, the initial value will be the size at the point of interruption. This
60  * is intended to help determine the direction of the size change (i.e. expand or collapse in x and
61  * y dimensions).
62  *
63  * @sample androidx.compose.animation.samples.AnimateContent
64  * @param animationSpec a finite animation that will be used to animate size change, [spring] by
65  *   default
66  * @param finishedListener an optional listener to be called when the content change animation is
67  *   completed.
68  */
animateContentSizenull69 public fun Modifier.animateContentSize(
70     animationSpec: FiniteAnimationSpec<IntSize> =
71         spring(
72             stiffness = Spring.StiffnessMediumLow,
73             visibilityThreshold = IntSize.VisibilityThreshold
74         ),
75     finishedListener: ((initialValue: IntSize, targetValue: IntSize) -> Unit)? = null
76 ): Modifier =
77     this.clipToBounds() then
78         SizeAnimationModifierElement(animationSpec, Alignment.TopStart, finishedListener)
79 
80 /**
81  * This modifier animates its own size when its child modifier (or the child composable if it is
82  * already at the tail of the chain) changes size. This allows the parent modifier to observe a
83  * smooth size change, resulting in an overall continuous visual change.
84  *
85  * A [FiniteAnimationSpec] can be optionally specified for the size change animation. By default,
86  * [spring] will be used.
87  *
88  * An optional [finishedListener] can be supplied to get notified when the size change animation is
89  * finished. Since the content size change can be dynamic in many cases, both initial value and
90  * target value (i.e. final size) will be passed to the [finishedListener]. __Note:__ if the
91  * animation is interrupted, the initial value will be the size at the point of interruption. This
92  * is intended to help determine the direction of the size change (i.e. expand or collapse in x and
93  * y dimensions).
94  *
95  * @sample androidx.compose.animation.samples.AnimateContent
96  * @param animationSpec a finite animation that will be used to animate size change, [spring] by
97  *   default
98  * @param alignment sets the alignment of the content during the animation. [Alignment.TopStart] by
99  *   default.
100  * @param finishedListener an optional listener to be called when the content change animation is
101  *   completed.
102  */
103 public fun Modifier.animateContentSize(
104     animationSpec: FiniteAnimationSpec<IntSize> =
105         spring(
106             stiffness = Spring.StiffnessMediumLow,
107             visibilityThreshold = IntSize.VisibilityThreshold
108         ),
109     alignment: Alignment = Alignment.TopStart,
110     finishedListener: ((initialValue: IntSize, targetValue: IntSize) -> Unit)? = null,
111 ): Modifier =
112     this.clipToBounds() then
113         SizeAnimationModifierElement(animationSpec, alignment, finishedListener)
114 
115 private data class SizeAnimationModifierElement(
116     val animationSpec: FiniteAnimationSpec<IntSize>,
117     val alignment: Alignment,
118     val finishedListener: ((initialValue: IntSize, targetValue: IntSize) -> Unit)?
119 ) : ModifierNodeElement<SizeAnimationModifierNode>() {
120     override fun create(): SizeAnimationModifierNode =
121         SizeAnimationModifierNode(animationSpec, alignment, finishedListener)
122 
123     override fun update(node: SizeAnimationModifierNode) {
124         node.animationSpec = animationSpec
125         node.listener = finishedListener
126         node.alignment = alignment
127     }
128 
129     override fun InspectorInfo.inspectableProperties() {
130         name = "animateContentSize"
131         properties["animationSpec"] = animationSpec
132         properties["alignment"] = alignment
133         properties["finishedListener"] = finishedListener
134     }
135 }
136 
137 internal val InvalidSize = IntSize(Int.MIN_VALUE, Int.MIN_VALUE)
138 internal val IntSize.isValid: Boolean
139     get() = this != InvalidSize
140 
141 /**
142  * This class creates a [LayoutModifier] that measures children, and responds to children's size
143  * change by animating to that size. The size reported to parents will be the animated size.
144  */
145 private class SizeAnimationModifierNode(
146     var animationSpec: AnimationSpec<IntSize>,
147     var alignment: Alignment = Alignment.TopStart,
148     var listener: ((startSize: IntSize, endSize: IntSize) -> Unit)? = null
149 ) : LayoutModifierNodeWithPassThroughIntrinsics() {
150     private var lookaheadSize: IntSize = InvalidSize
151     private var lookaheadConstraints: Constraints = Constraints()
152         set(value) {
153             field = value
154             lookaheadConstraintsAvailable = true
155         }
156 
157     private var lookaheadConstraintsAvailable: Boolean = false
158 
targetConstraintsnull159     private fun targetConstraints(default: Constraints) =
160         if (lookaheadConstraintsAvailable) {
161             lookaheadConstraints
162         } else {
163             default
164         }
165 
166     data class AnimData(val anim: Animatable<IntSize, AnimationVector2D>, var startSize: IntSize)
167 
168     var animData: AnimData? by mutableStateOf(null)
169 
onResetnull170     override fun onReset() {
171         super.onReset()
172         // Reset is an indication that the node may be re-used, in such case, animData becomes stale
173         animData = null
174     }
175 
onAttachnull176     override fun onAttach() {
177         super.onAttach()
178         // When re-attached, we may be attached to a tree without lookahead scope.
179         lookaheadSize = InvalidSize
180         lookaheadConstraintsAvailable = false
181     }
182 
measurenull183     override fun MeasureScope.measure(
184         measurable: Measurable,
185         constraints: Constraints
186     ): MeasureResult {
187         val placeable =
188             if (isLookingAhead) {
189                 lookaheadConstraints = constraints
190                 measurable.measure(constraints)
191             } else {
192                 // Measure with lookahead constraints when available, to avoid unnecessary relayout
193                 // in child during the lookahead animation.
194                 measurable.measure(targetConstraints(constraints))
195             }
196         val measuredSize = IntSize(placeable.width, placeable.height)
197         val (width, height) =
198             if (isLookingAhead) {
199                 lookaheadSize = measuredSize
200                 measuredSize
201             } else {
202                 animateTo(if (lookaheadSize.isValid) lookaheadSize else measuredSize).let {
203                     // Constrain the measure result to incoming constraints, so that parent doesn't
204                     // force center this layout.
205                     constraints.constrain(it)
206                 }
207             }
208         return layout(width, height) {
209             val offset =
210                 alignment.align(
211                     size = measuredSize,
212                     space = IntSize(width, height),
213                     layoutDirection = this@measure.layoutDirection
214                 )
215             placeable.place(offset)
216         }
217     }
218 
animateTonull219     fun animateTo(targetSize: IntSize): IntSize {
220         val data =
221             animData?.apply {
222                 // TODO(b/322878517): Figure out a way to seamlessly continue the animation after
223                 //  re-attach. Note that in some cases restarting the animation is the correct
224                 // behavior.
225                 val wasInterrupted = (targetSize != anim.value && !anim.isRunning)
226 
227                 if (targetSize != anim.targetValue || wasInterrupted) {
228                     startSize = anim.value
229                     coroutineScope.launch {
230                         val result = anim.animateTo(targetSize, animationSpec)
231                         if (result.endReason == AnimationEndReason.Finished) {
232                             listener?.invoke(startSize, result.endState.value)
233                         }
234                     }
235                 }
236             }
237                 ?: AnimData(
238                     Animatable(targetSize, IntSize.VectorConverter, IntSize(1, 1)),
239                     targetSize
240                 )
241 
242         animData = data
243         return data.anim.value
244     }
245 }
246 
247 internal abstract class LayoutModifierNodeWithPassThroughIntrinsics :
248     LayoutModifierNode, Modifier.Node() {
minIntrinsicWidthnull249     override fun IntrinsicMeasureScope.minIntrinsicWidth(
250         measurable: IntrinsicMeasurable,
251         height: Int
252     ) = measurable.minIntrinsicWidth(height)
253 
254     override fun IntrinsicMeasureScope.minIntrinsicHeight(
255         measurable: IntrinsicMeasurable,
256         width: Int
257     ) = measurable.minIntrinsicHeight(width)
258 
259     override fun IntrinsicMeasureScope.maxIntrinsicWidth(
260         measurable: IntrinsicMeasurable,
261         height: Int
262     ) = measurable.maxIntrinsicWidth(height)
263 
264     override fun IntrinsicMeasureScope.maxIntrinsicHeight(
265         measurable: IntrinsicMeasurable,
266         width: Int
267     ) = measurable.maxIntrinsicHeight(width)
268 }
269