1
+ package org.michaelbel.core.placeholder
2
+
3
+ import androidx.compose.animation.core.FiniteAnimationSpec
4
+ import androidx.compose.animation.core.InfiniteRepeatableSpec
5
+ import androidx.compose.animation.core.MutableTransitionState
6
+ import androidx.compose.animation.core.RepeatMode
7
+ import androidx.compose.animation.core.Transition
8
+ import androidx.compose.animation.core.animateFloat
9
+ import androidx.compose.animation.core.infiniteRepeatable
10
+ import androidx.compose.animation.core.rememberInfiniteTransition
11
+ import androidx.compose.animation.core.spring
12
+ import androidx.compose.animation.core.tween
13
+ import androidx.compose.animation.core.updateTransition
14
+ import androidx.compose.runtime.Composable
15
+ import androidx.compose.runtime.getValue
16
+ import androidx.compose.runtime.mutableFloatStateOf
17
+ import androidx.compose.runtime.remember
18
+ import androidx.compose.runtime.setValue
19
+ import androidx.compose.ui.Modifier
20
+ import androidx.compose.ui.composed
21
+ import androidx.compose.ui.draw.drawWithContent
22
+ import androidx.compose.ui.geometry.Size
23
+ import androidx.compose.ui.geometry.toRect
24
+ import androidx.compose.ui.graphics.Color
25
+ import androidx.compose.ui.graphics.Outline
26
+ import androidx.compose.ui.graphics.Paint
27
+ import androidx.compose.ui.graphics.RectangleShape
28
+ import androidx.compose.ui.graphics.Shape
29
+ import androidx.compose.ui.graphics.drawOutline
30
+ import androidx.compose.ui.graphics.drawscope.DrawScope
31
+ import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
32
+ import androidx.compose.ui.node.Ref
33
+ import androidx.compose.ui.platform.debugInspectorInfo
34
+ import androidx.compose.ui.unit.LayoutDirection
35
+
36
+ /* *
37
+ * Contains default values used by [Modifier.placeholder] and [PlaceholderHighlight].
38
+ */
39
+ internal object PlaceholderDefaults {
40
+ /* *
41
+ * The default [InfiniteRepeatableSpec] to use for [fade].
42
+ */
43
+ val fadeAnimationSpec: InfiniteRepeatableSpec <Float > by lazy {
44
+ infiniteRepeatable(
45
+ animation = tween(delayMillis = 200 , durationMillis = 600 ),
46
+ repeatMode = RepeatMode .Reverse ,
47
+ )
48
+ }
49
+
50
+ /* *
51
+ * The default [InfiniteRepeatableSpec] to use for [shimmer].
52
+ */
53
+ val shimmerAnimationSpec: InfiniteRepeatableSpec <Float > by lazy {
54
+ infiniteRepeatable(
55
+ animation = tween(durationMillis = 1700 , delayMillis = 200 ),
56
+ repeatMode = RepeatMode .Restart
57
+ )
58
+ }
59
+ }
60
+
61
+ /* *
62
+ * Draws some skeleton UI which is typically used whilst content is 'loading'.
63
+ *
64
+ * A version of this modifier which uses appropriate values for Material themed apps is available
65
+ * in the 'Placeholder Material' library.
66
+ *
67
+ * You can provide a [PlaceholderHighlight] which runs an highlight animation on the placeholder.
68
+ * The [shimmer] and [fade] implementations are provided for easy usage.
69
+ *
70
+ * A cross-fade transition will be applied to the content and placeholder UI when the [visible]
71
+ * value changes. The transition can be customized via the [contentFadeTransitionSpec] and
72
+ * [placeholderFadeTransitionSpec] parameters.
73
+ *
74
+ * You can find more information on the pattern at the Material Theming
75
+ * [Placeholder UI](https://material.io/design/communication/launch-screen.html#placeholder-ui)
76
+ * guidelines.
77
+ *
78
+ * @param visible whether the placeholder should be visible or not.
79
+ * @param color the color used to draw the placeholder UI.
80
+ * @param shape desired shape of the placeholder. Defaults to [RectangleShape].
81
+ * @param highlight optional highlight animation.
82
+ * @param placeholderFadeTransitionSpec The transition spec to use when fading the placeholder
83
+ * on/off screen. The boolean parameter defined for the transition is [visible].
84
+ * @param contentFadeTransitionSpec The transition spec to use when fading the content
85
+ * on/off screen. The boolean parameter defined for the transition is [visible].
86
+ */
87
+ fun Modifier.placeholder (
88
+ visible : Boolean ,
89
+ color : Color ,
90
+ shape : Shape = RectangleShape ,
91
+ highlight : PlaceholderHighlight ? = null,
92
+ placeholderFadeTransitionSpec : @Composable Transition .Segment <Boolean >.() -> FiniteAnimationSpec <Float > = { spring() },
93
+ contentFadeTransitionSpec : @Composable Transition .Segment <Boolean >.() -> FiniteAnimationSpec <Float > = { spring() }
94
+ ): Modifier = composed(
95
+ inspectorInfo = debugInspectorInfo {
96
+ name = " placeholder"
97
+ value = visible
98
+ properties[" visible" ] = visible
99
+ properties[" color" ] = color
100
+ properties[" highlight" ] = highlight
101
+ properties[" shape" ] = shape
102
+ }
103
+ ) {
104
+ // Values used for caching purposes
105
+ val lastSize = remember { Ref <Size >() }
106
+ val lastLayoutDirection = remember { Ref <LayoutDirection >() }
107
+ val lastOutline = remember { Ref <Outline >() }
108
+
109
+ // The current highlight animation progress
110
+ var highlightProgress: Float by remember { mutableFloatStateOf(0F ) }
111
+
112
+ // This is our crossfade transition
113
+ val transitionState = remember { MutableTransitionState (visible) }.apply {
114
+ targetState = visible
115
+ }
116
+ val transition = updateTransition(transitionState, " placeholder_crossfade" )
117
+
118
+ val placeholderAlpha by transition.animateFloat(
119
+ transitionSpec = placeholderFadeTransitionSpec,
120
+ label = " placeholder_fade" ,
121
+ targetValueByState = { placeholderVisible -> if (placeholderVisible) 1F else 0F }
122
+ )
123
+ val contentAlpha by transition.animateFloat(
124
+ transitionSpec = contentFadeTransitionSpec,
125
+ label = " content_fade" ,
126
+ targetValueByState = { placeholderVisible -> if (placeholderVisible) 0F else 1F }
127
+ )
128
+
129
+ // Run the optional animation spec and update the progress if the placeholder is visible
130
+ val animationSpec = highlight?.animationSpec
131
+ if (animationSpec != null && (visible || placeholderAlpha >= 0.01F )) {
132
+ val infiniteTransition = rememberInfiniteTransition(label = " " )
133
+ highlightProgress = infiniteTransition.animateFloat(
134
+ initialValue = 0F ,
135
+ targetValue = 1F ,
136
+ animationSpec = animationSpec,
137
+ label = " "
138
+ ).value
139
+ }
140
+
141
+ val paint = remember { Paint () }
142
+ remember(color, shape, highlight) {
143
+ drawWithContent {
144
+ // Draw the composable content first
145
+ if (contentAlpha in 0.01F .. 0.99F ) {
146
+ // If the content alpha is between 1% and 99%, draw it in a layer with
147
+ // the alpha applied
148
+ paint.alpha = contentAlpha
149
+ withLayer(paint) {
150
+ with (this @drawWithContent) {
151
+ drawContent()
152
+ }
153
+ }
154
+ } else if (contentAlpha >= 0.99F ) {
155
+ // If the content alpha is > 99%, draw it with no alpha
156
+ drawContent()
157
+ }
158
+
159
+ if (placeholderAlpha in 0.01F .. 0.99F ) {
160
+ // If the placeholder alpha is between 1% and 99%, draw it in a layer with
161
+ // the alpha applied
162
+ paint.alpha = placeholderAlpha
163
+ withLayer(paint) {
164
+ lastOutline.value = drawPlaceholder(
165
+ shape = shape,
166
+ color = color,
167
+ highlight = highlight,
168
+ progress = highlightProgress,
169
+ lastOutline = lastOutline.value,
170
+ lastLayoutDirection = lastLayoutDirection.value,
171
+ lastSize = lastSize.value,
172
+ )
173
+ }
174
+ } else if (placeholderAlpha >= 0.99F ) {
175
+ // If the placeholder alpha is > 99%, draw it with no alpha
176
+ lastOutline.value = drawPlaceholder(
177
+ shape = shape,
178
+ color = color,
179
+ highlight = highlight,
180
+ progress = highlightProgress,
181
+ lastOutline = lastOutline.value,
182
+ lastLayoutDirection = lastLayoutDirection.value,
183
+ lastSize = lastSize.value,
184
+ )
185
+ }
186
+
187
+ // Keep track of the last size & layout direction
188
+ lastSize.value = size
189
+ lastLayoutDirection.value = layoutDirection
190
+ }
191
+ }
192
+ }
193
+
194
+ private fun DrawScope.drawPlaceholder (
195
+ shape : Shape ,
196
+ color : Color ,
197
+ highlight : PlaceholderHighlight ? ,
198
+ progress : Float ,
199
+ lastOutline : Outline ? ,
200
+ lastLayoutDirection : LayoutDirection ? ,
201
+ lastSize : Size ? ,
202
+ ): Outline ? {
203
+ // shortcut to avoid Outline calculation and allocation
204
+ if (shape == = RectangleShape ) {
205
+ // Draw the initial background color
206
+ drawRect(color = color)
207
+
208
+ if (highlight != null ) {
209
+ drawRect(
210
+ brush = highlight.brush(progress, size),
211
+ alpha = highlight.alpha(progress),
212
+ )
213
+ }
214
+ // We didn't create an outline so return null
215
+ return null
216
+ }
217
+
218
+ // Otherwise we need to create an outline from the shape
219
+ val outline = lastOutline.takeIf {
220
+ size == lastSize && layoutDirection == lastLayoutDirection
221
+ } ? : shape.createOutline(size, layoutDirection, this )
222
+
223
+ // Draw the placeholder color
224
+ drawOutline(outline = outline, color = color)
225
+
226
+ if (highlight != null ) {
227
+ drawOutline(
228
+ outline = outline,
229
+ brush = highlight.brush(progress, size),
230
+ alpha = highlight.alpha(progress),
231
+ )
232
+ }
233
+
234
+ // Return the outline we used
235
+ return outline
236
+ }
237
+
238
+ private inline fun DrawScope.withLayer (
239
+ paint : Paint ,
240
+ drawBlock : DrawScope .() -> Unit ,
241
+ ) = drawIntoCanvas { canvas ->
242
+ canvas.saveLayer(size.toRect(), paint)
243
+ drawBlock()
244
+ canvas.restore()
245
+ }
0 commit comments