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