1 /*
2 * 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
18
19 import androidx.compose.foundation.gestures.Orientation
20 import androidx.compose.runtime.Stable
21 import androidx.compose.ui.Modifier
22 import androidx.compose.ui.layout.Measurable
23 import androidx.compose.ui.layout.MeasureResult
24 import androidx.compose.ui.layout.MeasureScope
25 import androidx.compose.ui.layout.layout
26 import androidx.compose.ui.node.LayoutModifierNode
27 import androidx.compose.ui.node.ModifierNodeElement
28 import androidx.compose.ui.unit.Constraints
29 import androidx.compose.ui.unit.Dp
30 import kotlin.math.roundToInt
31
32 /** A component that can bounce in one dimension, for instance when it is tapped. */
33 @Stable
34 interface Bounceable {
35 val bounce: Dp
36 }
37
38 /**
39 * Bounce a composable in the given [orientation] when this [bounceable], the [previousBounceable]
40 * or [nextBounceable] is bouncing.
41 *
42 * Important: This modifier should be used on composables that have a fixed size in [orientation],
43 * i.e. they should be placed *after* modifiers like Modifier.fillMaxWidth() or Modifier.height().
44 *
45 * @param bounceable the [Bounceable] associated to the current composable that will make this
46 * composable size grow when bouncing.
47 * @param previousBounceable the [Bounceable] associated to the previous composable in [orientation]
48 * that will make this composable shrink when bouncing.
49 * @param nextBounceable the [Bounceable] associated to the next composable in [orientation] that
50 * will make this composable shrink when bouncing.
51 * @param orientation the orientation in which this bounceable should grow/shrink.
52 * @param bounceEnd whether this bounceable should bounce on the end (right in LTR layouts, left in
53 * RTL layouts) side. This can be used for grids for which the last item does not align perfectly
54 * with the end of the grid.
55 */
56 @Stable
Modifiernull57 fun Modifier.bounceable(
58 bounceable: Bounceable,
59 previousBounceable: Bounceable?,
60 nextBounceable: Bounceable?,
61 orientation: Orientation,
62 bounceEnd: Boolean = nextBounceable != null,
63 ): Modifier {
64 return this then
65 BounceableElement(bounceable, previousBounceable, nextBounceable, orientation, bounceEnd)
66 }
67
68 private data class BounceableElement(
69 private val bounceable: Bounceable,
70 private val previousBounceable: Bounceable?,
71 private val nextBounceable: Bounceable?,
72 private val orientation: Orientation,
73 private val bounceEnd: Boolean,
74 ) : ModifierNodeElement<BounceableNode>() {
createnull75 override fun create(): BounceableNode {
76 return BounceableNode(
77 bounceable,
78 previousBounceable,
79 nextBounceable,
80 orientation,
81 bounceEnd,
82 )
83 }
84
updatenull85 override fun update(node: BounceableNode) {
86 node.bounceable = bounceable
87 node.previousBounceable = previousBounceable
88 node.nextBounceable = nextBounceable
89 node.orientation = orientation
90 node.bounceEnd = bounceEnd
91 }
92 }
93
94 private class BounceableNode(
95 var bounceable: Bounceable,
96 var previousBounceable: Bounceable?,
97 var nextBounceable: Bounceable?,
98 var orientation: Orientation,
99 var bounceEnd: Boolean = nextBounceable != null,
100 ) : Modifier.Node(), LayoutModifierNode {
measurenull101 override fun MeasureScope.measure(
102 measurable: Measurable,
103 constraints: Constraints,
104 ): MeasureResult {
105 // The constraints in the orientation should be fixed, otherwise there is no way to know
106 // what the size of our child node will be without this animation code.
107 checkFixedSize(constraints, orientation)
108
109 var sizePrevious = 0f
110 var sizeNext = 0f
111
112 val previousBounceable = previousBounceable
113 if (previousBounceable != null) {
114 sizePrevious += bounceable.bounce.toPx() - previousBounceable.bounce.toPx()
115 }
116
117 val nextBounceable = nextBounceable
118 if (nextBounceable != null) {
119 sizeNext += bounceable.bounce.toPx() - nextBounceable.bounce.toPx()
120 } else if (bounceEnd) {
121 sizeNext += bounceable.bounce.toPx()
122 }
123
124 when (orientation) {
125 Orientation.Horizontal -> {
126 val idleWidth = constraints.maxWidth
127 val animatedWidth = (idleWidth + sizePrevious + sizeNext).roundToInt()
128 val animatedConstraints =
129 constraints.copy(minWidth = animatedWidth, maxWidth = animatedWidth)
130
131 val placeable = measurable.measure(animatedConstraints)
132
133 // Important: we still place the element using the idle size coming from the
134 // constraints, otherwise the parent will automatically center this node given the
135 // size that it expects us to be. This allows us to then place the element where we
136 // want it to be.
137 return layout(idleWidth, placeable.height) {
138 placeable.placeRelative(-sizePrevious.roundToInt(), 0)
139 }
140 }
141 Orientation.Vertical -> {
142 val idleHeight = constraints.maxHeight
143 val animatedHeight = (idleHeight + sizePrevious + sizeNext).roundToInt()
144 val animatedConstraints =
145 constraints.copy(minHeight = animatedHeight, maxHeight = animatedHeight)
146
147 val placeable = measurable.measure(animatedConstraints)
148 return layout(placeable.width, idleHeight) {
149 placeable.placeRelative(0, -sizePrevious.roundToInt())
150 }
151 }
152 }
153 }
154 }
155
checkFixedSizenull156 private fun checkFixedSize(constraints: Constraints, orientation: Orientation) {
157 when (orientation) {
158 Orientation.Horizontal -> {
159 check(constraints.hasFixedWidth) {
160 "Modifier.bounceable() should receive a fixed width from its parent. Make sure " +
161 "that it is used *after* a fixed-width Modifier in the horizontal axis (like" +
162 " Modifier.fillMaxWidth() or Modifier.width())."
163 }
164 }
165 Orientation.Vertical -> {
166 check(constraints.hasFixedHeight) {
167 "Modifier.bounceable() should receive a fixed height from its parent. Make sure " +
168 "that it is used *after* a fixed-height Modifier in the vertical axis (like" +
169 " Modifier.fillMaxHeight() or Modifier.height())."
170 }
171 }
172 }
173 }
174