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.core.AnimationSpec
23 import androidx.compose.animation.core.animateDpAsState
24 import androidx.compose.animation.core.tween
25 import androidx.compose.animation.expandVertically
26 import androidx.compose.animation.fadeIn
27 import androidx.compose.animation.fadeOut
28 import androidx.compose.animation.scaleIn
29 import androidx.compose.animation.scaleOut
30 import androidx.compose.animation.shrinkVertically
31 import androidx.compose.foundation.layout.Box
32 import androidx.compose.foundation.layout.Column
33 import androidx.compose.foundation.layout.RowScope
34 import androidx.compose.foundation.layout.fillMaxWidth
35 import androidx.compose.foundation.layout.padding
36 import androidx.compose.foundation.layout.size
37 import androidx.compose.foundation.shape.RoundedCornerShape
38 import androidx.compose.material3.Icon
39 import androidx.compose.material3.IconButton
40 import androidx.compose.material3.IconButtonDefaults
41 import androidx.compose.material3.MaterialTheme
42 import androidx.compose.runtime.Composable
43 import androidx.compose.runtime.State
44 import androidx.compose.runtime.getValue
45 import androidx.compose.ui.Alignment
46 import androidx.compose.ui.Modifier
47 import androidx.compose.ui.res.painterResource
48 import androidx.compose.ui.res.stringResource
49 import androidx.compose.ui.semantics.Role
50 import androidx.compose.ui.semantics.role
51 import androidx.compose.ui.semantics.semantics
52 import androidx.compose.ui.semantics.stateDescription
53 import androidx.compose.ui.unit.Dp
54 import androidx.compose.ui.unit.dp
55 import androidx.lifecycle.compose.collectAsStateWithLifecycle
56 import com.android.compose.PlatformIconButton
57 import com.android.compose.PlatformSliderColors
58 import com.android.compose.modifiers.padding
59 import com.android.compose.modifiers.thenIf
60 import com.android.systemui.Flags
61 import com.android.systemui.res.R
62 import com.android.systemui.volume.panel.component.volume.slider.ui.viewmodel.SliderViewModel
63
64 private const val EXPAND_DURATION_MILLIS = 500
65 private const val COLLAPSE_EXPAND_BUTTON_DELAY_MILLIS = 350
66 private const val COLLAPSE_DURATION_MILLIS = 300
67 private const val EXPAND_BUTTON_ANIMATION_DURATION_MILLIS = 350
68 private const val TOP_SLIDER_ANIMATION_DURATION_MILLIS = 400
69 private const val SHRINK_FRACTION = 0.55f
70 private const val SCALE_FRACTION = 0.9f
71 private const val EXPAND_BUTTON_SCALE = 0.8f
72
73 @Composable
74 fun ColumnVolumeSliders(
75 viewModels: List<SliderViewModel>,
76 isExpanded: Boolean,
77 onExpandedChanged: (Boolean) -> Unit,
78 sliderColors: PlatformSliderColors,
79 isExpandable: Boolean,
80 modifier: Modifier = Modifier,
81 ) {
82 require(viewModels.isNotEmpty())
83 Column(modifier = modifier) {
84 Box(modifier = Modifier.fillMaxWidth()) {
85 val sliderViewModel: SliderViewModel = viewModels.first()
86 val sliderState by viewModels.first().slider.collectAsStateWithLifecycle()
87 val sliderPadding by topSliderPadding(isExpandable)
88
89 VolumeSlider(
90 modifier =
91 Modifier.thenIf(!Flags.volumeRedesign()) {
92 Modifier.padding(end = { sliderPadding.roundToPx() })
93 }
94 .fillMaxWidth(),
95 state = sliderState,
96 onValueChange = { newValue: Float ->
97 sliderViewModel.onValueChanged(sliderState, newValue)
98 },
99 onValueChangeFinished = { sliderViewModel.onValueChangeFinished() },
100 onIconTapped = { sliderViewModel.toggleMuted(sliderState) },
101 sliderColors = sliderColors,
102 hapticsViewModelFactory = sliderViewModel.getSliderHapticsViewModelFactory(),
103 button =
104 if (Flags.volumeRedesign()) {
105 {
106 ExpandButton(
107 isExpanded = isExpanded,
108 isExpandable = isExpandable,
109 onExpandedChanged = onExpandedChanged,
110 )
111 }
112 } else {
113 null
114 },
115 )
116
117 if (!Flags.volumeRedesign()) {
118 ExpandButtonLegacy(
119 modifier = Modifier.align(Alignment.CenterEnd),
120 isExpanded = isExpanded,
121 isExpandable = isExpandable,
122 onExpandedChanged = onExpandedChanged,
123 sliderColors = sliderColors,
124 )
125 }
126 }
127 AnimatedVisibility(
128 visible = isExpanded || !isExpandable,
129 label = "CollapsableSliders",
130 enter =
131 expandVertically(animationSpec = tween(durationMillis = EXPAND_DURATION_MILLIS)),
132 exit =
133 shrinkVertically(animationSpec = tween(durationMillis = COLLAPSE_DURATION_MILLIS)),
134 ) {
135 // This box allows sliders to slide towards top when the container is shrinking and
136 // slide from top when the container is expanding.
137 Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.BottomCenter) {
138 Column {
139 for (index in 1..viewModels.lastIndex) {
140 val sliderViewModel: SliderViewModel = viewModels[index]
141 val sliderState by sliderViewModel.slider.collectAsStateWithLifecycle()
142
143 VolumeSlider(
144 modifier =
145 Modifier.fillMaxWidth()
146 .animateEnterExit(
147 enter =
148 enterTransition(
149 index = index,
150 totalCount = viewModels.size,
151 ),
152 exit =
153 exitTransition(
154 index = index,
155 totalCount = viewModels.size,
156 ),
157 )
158 .padding(top = if (Flags.volumeRedesign()) 4.dp else 16.dp),
159 state = sliderState,
160 onValueChange = { newValue: Float ->
161 sliderViewModel.onValueChanged(sliderState, newValue)
162 },
163 onValueChangeFinished = { sliderViewModel.onValueChangeFinished() },
164 onIconTapped = { sliderViewModel.toggleMuted(sliderState) },
165 sliderColors = sliderColors,
166 hapticsViewModelFactory =
167 sliderViewModel.getSliderHapticsViewModelFactory(),
168 )
169 }
170 }
171 }
172 }
173 }
174 }
175
176 @Composable
ExpandButtonLegacynull177 private fun ExpandButtonLegacy(
178 isExpanded: Boolean,
179 isExpandable: Boolean,
180 onExpandedChanged: (Boolean) -> Unit,
181 sliderColors: PlatformSliderColors,
182 modifier: Modifier = Modifier,
183 ) {
184 val expandButtonStateDescription =
185 if (isExpanded) {
186 stringResource(R.string.volume_panel_expanded_sliders)
187 } else {
188 stringResource(R.string.volume_panel_collapsed_sliders)
189 }
190 AnimatedVisibility(
191 modifier = modifier,
192 visible = isExpandable,
193 enter = expandButtonEnterTransition(),
194 exit = expandButtonExitTransition(),
195 ) {
196 IconButton(
197 modifier =
198 Modifier.size(64.dp).semantics {
199 role = Role.Switch
200 stateDescription = expandButtonStateDescription
201 },
202 onClick = { onExpandedChanged(!isExpanded) },
203 colors =
204 IconButtonDefaults.filledIconButtonColors(
205 containerColor = sliderColors.indicatorColor,
206 contentColor = sliderColors.iconColor,
207 ),
208 ) {
209 Icon(
210 painter =
211 painterResource(
212 if (isExpanded) {
213 R.drawable.ic_filled_arrow_down
214 } else {
215 R.drawable.ic_filled_arrow_up
216 }
217 ),
218 contentDescription = null,
219 )
220 }
221 }
222 }
223
224 @Composable
ExpandButtonnull225 private fun RowScope.ExpandButton(
226 isExpanded: Boolean,
227 isExpandable: Boolean,
228 onExpandedChanged: (Boolean) -> Unit,
229 modifier: Modifier = Modifier,
230 ) {
231 val expandButtonStateDescription =
232 if (isExpanded) {
233 stringResource(R.string.volume_panel_expanded_sliders)
234 } else {
235 stringResource(R.string.volume_panel_collapsed_sliders)
236 }
237 AnimatedVisibility(
238 modifier = modifier,
239 visible = isExpandable,
240 enter = expandButtonEnterTransition(),
241 exit = expandButtonExitTransition(),
242 ) {
243 PlatformIconButton(
244 modifier =
245 Modifier.size(40.dp).semantics {
246 role = Role.Switch
247 stateDescription = expandButtonStateDescription
248 },
249 onClick = { onExpandedChanged(!isExpanded) },
250 colors =
251 IconButtonDefaults.iconButtonColors(
252 containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
253 contentColor = MaterialTheme.colorScheme.onSurface,
254 ),
255 shape = RoundedCornerShape(12.dp),
256 iconResource =
257 if (isExpanded) {
258 R.drawable.ic_arrow_down_24dp
259 } else {
260 R.drawable.ic_arrow_up_24dp
261 },
262 contentDescription = null,
263 )
264 }
265 }
266
enterTransitionnull267 private fun enterTransition(index: Int, totalCount: Int): EnterTransition {
268 val enterDelay = ((totalCount - index + 1) * 10).coerceAtLeast(0)
269 val enterDuration = (EXPAND_DURATION_MILLIS - enterDelay).coerceAtLeast(100)
270 return scaleIn(
271 initialScale = SCALE_FRACTION,
272 animationSpec = tween(durationMillis = enterDuration, delayMillis = enterDelay),
273 ) +
274 expandVertically(
275 initialHeight = { (it * SHRINK_FRACTION).toInt() },
276 animationSpec = tween(durationMillis = enterDuration, delayMillis = enterDelay),
277 clip = false,
278 ) +
279 fadeIn(animationSpec = tween(durationMillis = enterDuration, delayMillis = enterDelay))
280 }
281
exitTransitionnull282 private fun exitTransition(index: Int, totalCount: Int): ExitTransition {
283 val exitDuration = (COLLAPSE_DURATION_MILLIS - (totalCount - index + 1) * 10).coerceAtLeast(100)
284 return scaleOut(
285 targetScale = SCALE_FRACTION,
286 animationSpec = tween(durationMillis = exitDuration),
287 ) +
288 shrinkVertically(
289 targetHeight = { (it * SHRINK_FRACTION).toInt() },
290 animationSpec = tween(durationMillis = exitDuration),
291 clip = false,
292 ) +
293 fadeOut(animationSpec = tween(durationMillis = exitDuration))
294 }
295
expandButtonEnterTransitionnull296 private fun expandButtonEnterTransition(): EnterTransition {
297 return fadeIn(
298 tween(
299 delayMillis = COLLAPSE_EXPAND_BUTTON_DELAY_MILLIS,
300 durationMillis = EXPAND_BUTTON_ANIMATION_DURATION_MILLIS,
301 )
302 ) +
303 scaleIn(
304 animationSpec =
305 tween(
306 delayMillis = COLLAPSE_EXPAND_BUTTON_DELAY_MILLIS,
307 durationMillis = EXPAND_BUTTON_ANIMATION_DURATION_MILLIS,
308 ),
309 initialScale = EXPAND_BUTTON_SCALE,
310 )
311 }
312
expandButtonExitTransitionnull313 private fun expandButtonExitTransition(): ExitTransition {
314 return fadeOut(
315 tween(
316 delayMillis = EXPAND_DURATION_MILLIS,
317 durationMillis = EXPAND_BUTTON_ANIMATION_DURATION_MILLIS,
318 )
319 ) +
320 scaleOut(
321 animationSpec =
322 tween(
323 delayMillis = EXPAND_DURATION_MILLIS,
324 durationMillis = EXPAND_BUTTON_ANIMATION_DURATION_MILLIS,
325 ),
326 targetScale = EXPAND_BUTTON_SCALE,
327 )
328 }
329
330 @Composable
topSliderPaddingnull331 private fun topSliderPadding(isExpandable: Boolean): State<Dp> {
332 val animationSpec: AnimationSpec<Dp> =
333 if (isExpandable) {
334 tween(
335 delayMillis = COLLAPSE_DURATION_MILLIS,
336 durationMillis = TOP_SLIDER_ANIMATION_DURATION_MILLIS,
337 )
338 } else {
339 tween(
340 delayMillis = EXPAND_DURATION_MILLIS,
341 durationMillis = TOP_SLIDER_ANIMATION_DURATION_MILLIS,
342 )
343 }
344 return animateDpAsState(
345 targetValue =
346 if (isExpandable) {
347 72.dp
348 } else {
349 0.dp
350 },
351 animationSpec = animationSpec,
352 label = "TopVolumeSliderPadding",
353 )
354 }
355