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.systemui.volume.panel.component.volume.ui.composable
18 
19 import androidx.compose.animation.AnimatedVisibility
20 import androidx.compose.animation.EnterTransition
21 import androidx.compose.animation.ExitTransition
22 import androidx.compose.animation.ExperimentalAnimationApi
23 import androidx.compose.animation.core.AnimationSpec
24 import androidx.compose.animation.core.animateDpAsState
25 import androidx.compose.animation.core.tween
26 import androidx.compose.animation.core.updateTransition
27 import androidx.compose.animation.expandVertically
28 import androidx.compose.animation.fadeIn
29 import androidx.compose.animation.fadeOut
30 import androidx.compose.animation.scaleIn
31 import androidx.compose.animation.scaleOut
32 import androidx.compose.animation.shrinkVertically
33 import androidx.compose.foundation.layout.Box
34 import androidx.compose.foundation.layout.Column
35 import androidx.compose.foundation.layout.fillMaxWidth
36 import androidx.compose.foundation.layout.padding
37 import androidx.compose.foundation.layout.size
38 import androidx.compose.material3.Icon
39 import androidx.compose.material3.IconButton
40 import androidx.compose.material3.IconButtonDefaults
41 import androidx.compose.runtime.Composable
42 import androidx.compose.runtime.State
43 import androidx.compose.runtime.getValue
44 import androidx.compose.ui.Alignment
45 import androidx.compose.ui.Modifier
46 import androidx.compose.ui.res.painterResource
47 import androidx.compose.ui.res.stringResource
48 import androidx.compose.ui.semantics.Role
49 import androidx.compose.ui.semantics.role
50 import androidx.compose.ui.semantics.semantics
51 import androidx.compose.ui.semantics.stateDescription
52 import androidx.compose.ui.unit.Dp
53 import androidx.compose.ui.unit.dp
54 import androidx.lifecycle.compose.collectAsStateWithLifecycle
55 import com.android.compose.PlatformSliderColors
56 import com.android.compose.modifiers.padding
57 import com.android.systemui.res.R
58 import com.android.systemui.volume.panel.component.volume.slider.ui.viewmodel.SliderViewModel
59 
60 private const val EXPAND_DURATION_MILLIS = 500
61 private const val COLLAPSE_EXPAND_BUTTON_DELAY_MILLIS = 350
62 private const val COLLAPSE_DURATION_MILLIS = 300
63 private const val EXPAND_BUTTON_ANIMATION_DURATION_MILLIS = 350
64 private const val TOP_SLIDER_ANIMATION_DURATION_MILLIS = 400
65 private const val SHRINK_FRACTION = 0.55f
66 private const val SCALE_FRACTION = 0.9f
67 private const val EXPAND_BUTTON_SCALE = 0.8f
68 
69 /** Volume sliders laid out in a collapsable column */
70 @OptIn(ExperimentalAnimationApi::class)
71 @Composable
72 fun ColumnVolumeSliders(
73     viewModels: List<SliderViewModel>,
74     isExpanded: Boolean,
75     onExpandedChanged: (Boolean) -> Unit,
76     sliderColors: PlatformSliderColors,
77     isExpandable: Boolean,
78     modifier: Modifier = Modifier,
79 ) {
80     require(viewModels.isNotEmpty())
81     val transition = updateTransition(isExpanded, label = "CollapsableSliders")
82     Column(modifier = modifier) {
83         Box(
84             modifier = Modifier.fillMaxWidth(),
85         ) {
86             val sliderViewModel: SliderViewModel = viewModels.first()
87             val sliderState by viewModels.first().slider.collectAsStateWithLifecycle()
88             val sliderPadding by topSliderPadding(isExpandable)
89 
90             VolumeSlider(
91                 modifier = Modifier.padding(end = { sliderPadding.roundToPx() }).fillMaxWidth(),
92                 state = sliderState,
93                 onValueChange = { newValue: Float ->
94                     sliderViewModel.onValueChanged(sliderState, newValue)
95                 },
96                 onValueChangeFinished = { sliderViewModel.onValueChangeFinished() },
97                 onIconTapped = { sliderViewModel.toggleMuted(sliderState) },
98                 sliderColors = sliderColors,
99             )
100 
101             ExpandButton(
102                 modifier = Modifier.align(Alignment.CenterEnd),
103                 isExpanded = isExpanded,
104                 isExpandable = isExpandable,
105                 onExpandedChanged = onExpandedChanged,
106                 sliderColors = sliderColors,
107             )
108         }
109         transition.AnimatedVisibility(
110             visible = { it || !isExpandable },
111             enter =
112                 expandVertically(animationSpec = tween(durationMillis = EXPAND_DURATION_MILLIS)),
113             exit =
114                 shrinkVertically(animationSpec = tween(durationMillis = COLLAPSE_DURATION_MILLIS)),
115         ) {
116             // This box allows sliders to slide towards top when the container is shrinking and
117             // slide from top when the container is expanding.
118             Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.BottomCenter) {
119                 Column {
120                     for (index in 1..viewModels.lastIndex) {
121                         val sliderViewModel: SliderViewModel = viewModels[index]
122                         val sliderState by sliderViewModel.slider.collectAsStateWithLifecycle()
123                         transition.AnimatedVisibility(
124                             modifier = Modifier.padding(top = 16.dp),
125                             visible = { it || !isExpandable },
126                             enter = enterTransition(index = index, totalCount = viewModels.size),
127                             exit = exitTransition(index = index, totalCount = viewModels.size)
128                         ) {
129                             VolumeSlider(
130                                 modifier = Modifier.fillMaxWidth(),
131                                 state = sliderState,
132                                 onValueChange = { newValue: Float ->
133                                     sliderViewModel.onValueChanged(sliderState, newValue)
134                                 },
135                                 onValueChangeFinished = { sliderViewModel.onValueChangeFinished() },
136                                 onIconTapped = { sliderViewModel.toggleMuted(sliderState) },
137                                 sliderColors = sliderColors,
138                             )
139                         }
140                     }
141                 }
142             }
143         }
144     }
145 }
146 
147 @Composable
ExpandButtonnull148 private fun ExpandButton(
149     isExpanded: Boolean,
150     isExpandable: Boolean,
151     onExpandedChanged: (Boolean) -> Unit,
152     sliderColors: PlatformSliderColors,
153     modifier: Modifier = Modifier,
154 ) {
155     val expandButtonStateDescription =
156         if (isExpanded) {
157             stringResource(R.string.volume_panel_expanded_sliders)
158         } else {
159             stringResource(R.string.volume_panel_collapsed_sliders)
160         }
161     AnimatedVisibility(
162         modifier = modifier,
163         visible = isExpandable,
164         enter = expandButtonEnterTransition(),
165         exit = expandButtonExitTransition(),
166     ) {
167         IconButton(
168             modifier =
169                 Modifier.size(64.dp).semantics {
170                     role = Role.Switch
171                     stateDescription = expandButtonStateDescription
172                 },
173             onClick = { onExpandedChanged(!isExpanded) },
174             colors =
175                 IconButtonDefaults.filledIconButtonColors(
176                     containerColor = sliderColors.indicatorColor,
177                     contentColor = sliderColors.iconColor
178                 ),
179         ) {
180             Icon(
181                 painter =
182                     painterResource(
183                         if (isExpanded) {
184                             R.drawable.ic_filled_arrow_down
185                         } else {
186                             R.drawable.ic_filled_arrow_up
187                         }
188                     ),
189                 contentDescription = null,
190             )
191         }
192     }
193 }
194 
enterTransitionnull195 private fun enterTransition(index: Int, totalCount: Int): EnterTransition {
196     val enterDelay = ((totalCount - index + 1) * 10).coerceAtLeast(0)
197     val enterDuration = (EXPAND_DURATION_MILLIS - enterDelay).coerceAtLeast(100)
198     return scaleIn(
199         initialScale = SCALE_FRACTION,
200         animationSpec = tween(durationMillis = enterDuration, delayMillis = enterDelay),
201     ) +
202         expandVertically(
203             initialHeight = { (it * SHRINK_FRACTION).toInt() },
204             animationSpec = tween(durationMillis = enterDuration, delayMillis = enterDelay),
205             clip = false,
206         ) +
207         fadeIn(
208             animationSpec = tween(durationMillis = enterDuration, delayMillis = enterDelay),
209         )
210 }
211 
exitTransitionnull212 private fun exitTransition(index: Int, totalCount: Int): ExitTransition {
213     val exitDuration = (COLLAPSE_DURATION_MILLIS - (totalCount - index + 1) * 10).coerceAtLeast(100)
214     return scaleOut(
215         targetScale = SCALE_FRACTION,
216         animationSpec = tween(durationMillis = exitDuration),
217     ) +
218         shrinkVertically(
219             targetHeight = { (it * SHRINK_FRACTION).toInt() },
220             animationSpec = tween(durationMillis = exitDuration),
221             clip = false,
222         ) +
223         fadeOut(animationSpec = tween(durationMillis = exitDuration))
224 }
225 
expandButtonEnterTransitionnull226 private fun expandButtonEnterTransition(): EnterTransition {
227     return fadeIn(
228         tween(
229             delayMillis = COLLAPSE_EXPAND_BUTTON_DELAY_MILLIS,
230             durationMillis = EXPAND_BUTTON_ANIMATION_DURATION_MILLIS,
231         )
232     ) +
233         scaleIn(
234             animationSpec =
235                 tween(
236                     delayMillis = COLLAPSE_EXPAND_BUTTON_DELAY_MILLIS,
237                     durationMillis = EXPAND_BUTTON_ANIMATION_DURATION_MILLIS,
238                 ),
239             initialScale = EXPAND_BUTTON_SCALE,
240         )
241 }
242 
expandButtonExitTransitionnull243 private fun expandButtonExitTransition(): ExitTransition {
244     return fadeOut(
245         tween(
246             delayMillis = EXPAND_DURATION_MILLIS,
247             durationMillis = EXPAND_BUTTON_ANIMATION_DURATION_MILLIS,
248         )
249     ) +
250         scaleOut(
251             animationSpec =
252                 tween(
253                     delayMillis = EXPAND_DURATION_MILLIS,
254                     durationMillis = EXPAND_BUTTON_ANIMATION_DURATION_MILLIS,
255                 ),
256             targetScale = EXPAND_BUTTON_SCALE,
257         )
258 }
259 
260 @Composable
topSliderPaddingnull261 private fun topSliderPadding(isExpandable: Boolean): State<Dp> {
262     val animationSpec: AnimationSpec<Dp> =
263         if (isExpandable) {
264             tween(
265                 delayMillis = COLLAPSE_DURATION_MILLIS,
266                 durationMillis = TOP_SLIDER_ANIMATION_DURATION_MILLIS,
267             )
268         } else {
269             tween(
270                 delayMillis = EXPAND_DURATION_MILLIS,
271                 durationMillis = TOP_SLIDER_ANIMATION_DURATION_MILLIS,
272             )
273         }
274     return animateDpAsState(
275         targetValue =
276             if (isExpandable) {
277                 72.dp
278             } else {
279                 0.dp
280             },
281         animationSpec = animationSpec,
282         label = "TopVolumeSliderPadding"
283     )
284 }
285