1 /*
2 * Copyright (C) 2023 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 @file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
18
19 package com.android.compose.animation.scene.demo
20
21 import androidx.compose.animation.core.FiniteAnimationSpec
22 import androidx.compose.animation.core.Spring
23 import androidx.compose.animation.core.spring
24 import androidx.compose.foundation.clickable
25 import androidx.compose.foundation.layout.Column
26 import androidx.compose.foundation.layout.Row
27 import androidx.compose.foundation.layout.fillMaxWidth
28 import androidx.compose.foundation.layout.padding
29 import androidx.compose.foundation.layout.width
30 import androidx.compose.foundation.rememberScrollState
31 import androidx.compose.foundation.verticalScroll
32 import androidx.compose.material.icons.Icons
33 import androidx.compose.material.icons.filled.Add
34 import androidx.compose.material.icons.filled.Remove
35 import androidx.compose.material3.AlertDialog
36 import androidx.compose.material3.Button
37 import androidx.compose.material3.Checkbox
38 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
39 import androidx.compose.material3.Icon
40 import androidx.compose.material3.IconButton
41 import androidx.compose.material3.MaterialTheme
42 import androidx.compose.material3.MotionScheme
43 import androidx.compose.material3.Slider
44 import androidx.compose.material3.Text
45 import androidx.compose.material3.TriStateCheckbox
46 import androidx.compose.runtime.Composable
47 import androidx.compose.runtime.saveable.mapSaver
48 import androidx.compose.ui.Alignment
49 import androidx.compose.ui.Modifier
50 import androidx.compose.ui.draw.clip
51 import androidx.compose.ui.state.ToggleableState
52 import androidx.compose.ui.text.style.TextAlign
53 import androidx.compose.ui.unit.dp
54 import kotlin.math.roundToInt
55
56 data class DemoConfiguration(
57 val notificationsInLockscreen: Int = 2,
58 val notificationsInShade: Int = 10,
59 val quickSettingsRows: Int = 4,
60 val interactiveNotifications: Boolean = false,
61 val showMediaPlayer: Boolean = true,
62 val isFullscreen: Boolean = false,
63 val canChangeSceneOrOverlays: Boolean = true,
64 val transitionInterceptionThreshold: Float = 0.05f,
65 val motion: MotionConfig = MotionConfig.Default,
66 val lsToShadeRequiresFullSwipe: ToggleableState = ToggleableState.Indeterminate,
67 val enableOverlays: Boolean = false,
68 val transitionBorder: Boolean = true,
69 val deferTransitionProgress: Boolean = false,
70 val firstCompositionDelay: Long = 0L,
71 ) {
72 companion object {
<lambda>null73 val Saver = run {
74 val notificationsInLockscreenKey = "notificationsInLockscreen"
75 val notificationsInShadeKey = "notificationsInShade"
76 val quickSettingsRowsKey = "quickSettingsRows"
77 val interactiveNotificationsKey = "interactiveNotifications"
78 val showMediaPlayerKey = "showMediaPlayer"
79 val isFullscreenKey = "isFullscreen"
80 val canChangeSceneOrOverlaysKey = "canChangeSceneOrOverlays"
81 val transitionInterceptionThresholdKey = "transitionInterceptionThreshold"
82 val motionSchemeKey = "motionScheme"
83 val lsToShadeRequiresFullSwipe = "lsToShadeRequiresFullSwipe"
84 val enableOverlays = "enableOverlays"
85 val transitionBorder = "transitionBorder"
86 val deferTransitionProgress = "deferTransitionProgress"
87 val firstCompositionDelay = "firstCompositionDelay"
88
89 mapSaver(
90 save = {
91 mapOf(
92 notificationsInLockscreenKey to it.notificationsInLockscreen,
93 notificationsInShadeKey to it.notificationsInShade,
94 quickSettingsRowsKey to it.quickSettingsRows,
95 interactiveNotificationsKey to it.interactiveNotifications,
96 showMediaPlayerKey to it.showMediaPlayer,
97 isFullscreenKey to it.isFullscreen,
98 canChangeSceneOrOverlaysKey to it.canChangeSceneOrOverlays,
99 transitionInterceptionThresholdKey to it.transitionInterceptionThreshold,
100 motionSchemeKey to it.motion.name,
101 lsToShadeRequiresFullSwipe to it.lsToShadeRequiresFullSwipe,
102 enableOverlays to it.enableOverlays,
103 transitionBorder to it.transitionBorder,
104 deferTransitionProgress to it.deferTransitionProgress,
105 firstCompositionDelay to it.firstCompositionDelay,
106 )
107 },
108 restore = {
109 DemoConfiguration(
110 notificationsInLockscreen = it[notificationsInLockscreenKey] as Int,
111 notificationsInShade = it[notificationsInShadeKey] as Int,
112 quickSettingsRows = it[quickSettingsRowsKey] as Int,
113 interactiveNotifications = it[interactiveNotificationsKey] as Boolean,
114 showMediaPlayer = it[showMediaPlayerKey] as Boolean,
115 isFullscreen = it[isFullscreenKey] as Boolean,
116 canChangeSceneOrOverlays = it[canChangeSceneOrOverlaysKey] as Boolean,
117 transitionInterceptionThreshold =
118 it[transitionInterceptionThresholdKey] as Float,
119 motion = MotionConfig.fromName(it[motionSchemeKey] as String),
120 lsToShadeRequiresFullSwipe =
121 it[lsToShadeRequiresFullSwipe] as ToggleableState,
122 enableOverlays = it[enableOverlays] as Boolean,
123 transitionBorder = it[transitionBorder] as Boolean,
124 deferTransitionProgress = it[deferTransitionProgress] as Boolean,
125 firstCompositionDelay = it[firstCompositionDelay] as Long,
126 )
127 },
128 )
129 }
130 }
131 }
132
133 class MotionConfig(val name: String, val scheme: MotionScheme) {
134 companion object {
135 val Options =
136 listOf(
137 MotionConfig("standard", MotionScheme.standard()),
138 MotionConfig("expressive", MotionScheme.expressive()),
139 MotionConfig(
140 "bouncy",
141 CustomMotionScheme(
142 spatial = spring(Spring.DampingRatioHighBouncy, Spring.StiffnessVeryLow),
143 effects = spring(Spring.DampingRatioNoBouncy, Spring.StiffnessVeryLow),
144 ),
145 ),
146 MotionConfig(
147 "stiff",
148 CustomMotionScheme(
149 spatial = spring(Spring.DampingRatioLowBouncy, Spring.StiffnessHigh),
150 effects = spring(Spring.DampingRatioNoBouncy, Spring.StiffnessHigh),
151 ),
152 ),
153 MotionConfig(
154 "high bouncy & stiff",
155 CustomMotionScheme(
156 spatial = spring(Spring.DampingRatioHighBouncy, Spring.StiffnessHigh),
157 effects = spring(Spring.DampingRatioNoBouncy, Spring.StiffnessHigh),
158 ),
159 ),
160 MotionConfig(
161 "low bouncy & stiff",
162 CustomMotionScheme(
163 spatial = spring(Spring.DampingRatioLowBouncy, Spring.StiffnessVeryLow),
164 effects = spring(Spring.DampingRatioNoBouncy, Spring.StiffnessVeryLow),
165 ),
166 ),
167 )
168
169 val Default: MotionConfig = Options[1]
170
<lambda>null171 fun fromName(name: String) = Options.first { it.name == name }
172 }
173
174 // Implementation inspired by MotionScheme.standard()
175 @Suppress("UNCHECKED_CAST")
176 class CustomMotionScheme(
177 private val spatial: FiniteAnimationSpec<Any>,
178 private val effects: FiniteAnimationSpec<Any>,
179 private val fastSpatial: FiniteAnimationSpec<Any> = spatial,
180 private val fastEffects: FiniteAnimationSpec<Any> = effects,
181 private val slowSpatial: FiniteAnimationSpec<Any> = spatial,
182 private val slowEffects: FiniteAnimationSpec<Any> = effects,
183 ) : MotionScheme {
defaultSpatialSpecnull184 override fun <T> defaultSpatialSpec() = spatial as FiniteAnimationSpec<T>
185
186 override fun <T> fastSpatialSpec() = fastSpatial as FiniteAnimationSpec<T>
187
188 override fun <T> slowSpatialSpec() = slowSpatial as FiniteAnimationSpec<T>
189
190 override fun <T> defaultEffectsSpec() = effects as FiniteAnimationSpec<T>
191
192 override fun <T> fastEffectsSpec() = fastEffects as FiniteAnimationSpec<T>
193
194 override fun <T> slowEffectsSpec() = slowEffects as FiniteAnimationSpec<T>
195 }
196 }
197
198 @Composable
199 fun DemoConfigurationDialog(
200 configuration: DemoConfiguration,
201 onConfigurationChange: (DemoConfiguration) -> Unit,
202 onDismissRequest: () -> Unit,
203 ) {
204 AlertDialog(
205 onDismissRequest = onDismissRequest,
206 title = { Text("Demo configuration") },
207 text = {
208 Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) {
209 Text(text = "Generic app settings", style = MaterialTheme.typography.titleMedium)
210
211 // Fullscreen.
212 Checkbox(
213 label = "Fullscreen",
214 checked = configuration.isFullscreen,
215 onCheckedChange = {
216 onConfigurationChange(
217 configuration.copy(isFullscreen = !configuration.isFullscreen)
218 )
219 },
220 )
221
222 // Can change scene.
223 Checkbox(
224 label = "Can change scene or overlays",
225 checked = configuration.canChangeSceneOrOverlays,
226 onCheckedChange = {
227 onConfigurationChange(
228 configuration.copy(
229 canChangeSceneOrOverlays = !configuration.canChangeSceneOrOverlays
230 )
231 )
232 },
233 )
234
235 // Overlays.
236 Checkbox(
237 label = "Overlays",
238 checked = configuration.enableOverlays,
239 onCheckedChange = {
240 onConfigurationChange(
241 configuration.copy(enableOverlays = !configuration.enableOverlays)
242 )
243 },
244 )
245
246 // Transition border.
247 Checkbox(
248 label = "Transition border",
249 checked = configuration.transitionBorder,
250 onCheckedChange = {
251 onConfigurationChange(
252 configuration.copy(transitionBorder = !configuration.transitionBorder)
253 )
254 },
255 )
256
257 // Defer transition progress.
258 Checkbox(
259 label = "Defer transition progress",
260 checked = configuration.deferTransitionProgress,
261 onCheckedChange = {
262 onConfigurationChange(
263 configuration.copy(
264 deferTransitionProgress = !configuration.deferTransitionProgress
265 )
266 )
267 },
268 )
269
270 // First composition delay.
271 Text(text = "First composition delay: ${configuration.firstCompositionDelay}ms")
272 Slider(
273 value = configuration.firstCompositionDelay,
274 onValueChange = {
275 onConfigurationChange(configuration.copy(firstCompositionDelay = it))
276 },
277 values = List(21) { it * 10L },
278 onValueNotFound = { 0 },
279 )
280
281 Text(text = "Theme", style = MaterialTheme.typography.titleMedium)
282
283 Text(text = "Motion: ${configuration.motion.name}")
284 Slider(
285 value = configuration.motion,
286 onValueChange = { onConfigurationChange(configuration.copy(motion = it)) },
287 values = MotionConfig.Options,
288 onValueNotFound = { 0 },
289 )
290
291 Text(text = "Scrollable", style = MaterialTheme.typography.titleMedium)
292
293 // Interception threshold.
294 val thresholdString =
295 String.format("%.2f", configuration.transitionInterceptionThreshold)
296 Text(text = "Interception threshold: $thresholdString")
297 Slider(
298 value = configuration.transitionInterceptionThreshold,
299 onValueChange = {
300 onConfigurationChange(
301 configuration.copy(transitionInterceptionThreshold = it)
302 )
303 },
304 valueRange = 0f..0.5f,
305 stepSize = 0.01f,
306 )
307
308 Text(text = "Media", style = MaterialTheme.typography.titleMedium)
309
310 // Whether we should show the media player.
311 Checkbox(
312 label = "Show media player",
313 checked = configuration.showMediaPlayer,
314 onCheckedChange = {
315 onConfigurationChange(
316 configuration.copy(showMediaPlayer = !configuration.showMediaPlayer)
317 )
318 },
319 )
320
321 Text(text = "Notifications", style = MaterialTheme.typography.titleMedium)
322
323 // Whether notifications are interactive
324 Checkbox(
325 label = "Interactive notifications",
326 checked = configuration.interactiveNotifications,
327 onCheckedChange = {
328 onConfigurationChange(
329 configuration.copy(
330 interactiveNotifications = !configuration.interactiveNotifications
331 )
332 )
333 },
334 )
335
336 // Number of notifications in the Shade scene.
337 Counter(
338 "# notifications in Shade",
339 configuration.notificationsInShade,
340 onValueChange = {
341 onConfigurationChange(configuration.copy(notificationsInShade = it))
342 },
343 )
344
345 // Number of notifications in the Lockscreen scene.
346 Counter(
347 "# notifications in Lockscreen",
348 configuration.notificationsInLockscreen,
349 onValueChange = {
350 onConfigurationChange(configuration.copy(notificationsInLockscreen = it))
351 },
352 )
353
354 Text(text = "Quick Settings", style = MaterialTheme.typography.titleMedium)
355
356 Counter(
357 "# quick settings rows",
358 configuration.quickSettingsRows,
359 onValueChange = {
360 onConfigurationChange(configuration.copy(quickSettingsRows = it))
361 },
362 )
363
364 Text(text = "Lockscreen", style = MaterialTheme.typography.titleMedium)
365
366 // Whether the LS => Shade transition requires a full distance swipe to be
367 // committed.
368 Checkbox(
369 label = "Require full LS => Shade swipe",
370 state = configuration.lsToShadeRequiresFullSwipe,
371 onStateChange = {
372 onConfigurationChange(configuration.copy(lsToShadeRequiresFullSwipe = it))
373 },
374 )
375 }
376 },
377 confirmButton = { Button(onClick = { onDismissRequest() }) { Text("Done") } },
378 dismissButton = {
379 Button(onClick = { onConfigurationChange(DemoConfiguration()) }) { Text("Reset") }
380 },
381 )
382 }
383
384 @Composable
Checkboxnull385 private fun Checkbox(
386 label: String,
387 checked: Boolean,
388 onCheckedChange: (Boolean) -> Unit,
389 modifier: Modifier = Modifier,
390 ) {
391 Row(
392 modifier
393 .fillMaxWidth()
394 .clip(MaterialTheme.shapes.small)
395 .clickable(onClick = { onCheckedChange(!checked) }),
396 verticalAlignment = Alignment.CenterVertically,
397 ) {
398 Checkbox(checked, onCheckedChange)
399 Text(label, Modifier.padding(start = 8.dp))
400 }
401 }
402
403 @Composable
Checkboxnull404 private fun Checkbox(
405 label: String,
406 state: ToggleableState,
407 onStateChange: (ToggleableState) -> Unit,
408 modifier: Modifier = Modifier,
409 ) {
410 fun onClick() {
411 onStateChange(
412 when (state) {
413 ToggleableState.On -> ToggleableState.Off
414 ToggleableState.Off -> ToggleableState.Indeterminate
415 ToggleableState.Indeterminate -> ToggleableState.On
416 }
417 )
418 }
419
420 Row(
421 modifier.fillMaxWidth().clip(MaterialTheme.shapes.small).clickable(onClick = ::onClick),
422 verticalAlignment = Alignment.CenterVertically,
423 ) {
424 TriStateCheckbox(state, onClick = ::onClick)
425 Text(label, Modifier.padding(start = 8.dp))
426 }
427 }
428
429 @Composable
Counternull430 private fun Counter(
431 label: String,
432 value: Int,
433 onValueChange: (Int) -> Unit,
434 modifier: Modifier = Modifier,
435 ) {
436 Row(modifier, verticalAlignment = Alignment.CenterVertically) {
437 IconButton(onClick = { onValueChange((value - 1).coerceAtLeast(0)) }) {
438 Icon(Icons.Default.Remove, null)
439 }
440 Text(value.toString(), Modifier.width(18.dp), textAlign = TextAlign.Center)
441 IconButton(onClick = { onValueChange((value + 1)) }) { Icon(Icons.Default.Add, null) }
442 Text(label, Modifier.padding(start = 8.dp))
443 }
444 }
445
446 @Composable
Slidernull447 private fun <T> Slider(
448 value: T,
449 onValueChange: (T) -> Unit,
450 values: List<T>,
451 onValueNotFound: () -> Int = { 0 },
452 ) {
453 Slider(
<lambda>null454 value = (values.indexOf(value).takeIf { it != -1 } ?: onValueNotFound()).toFloat(),
<lambda>null455 onValueChange = { onValueChange(values[it.roundToInt()]) },
456 valueRange = 0f..values.lastIndex.toFloat(),
457 steps = values.lastIndex - 1,
458 )
459 }
460
461 @Composable
Slidernull462 private fun Slider(
463 value: Float,
464 onValueChange: (Float) -> Unit,
465 valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
466 stepSize: Float,
467 ) {
468 Slider(
469 value = value,
470 onValueChange = onValueChange,
471 valueRange = valueRange,
472 steps = ((valueRange.endInclusive - valueRange.start) / stepSize).toInt() - 1,
473 )
474 }
475