1 /*
2 * 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.selector.ui.composable
18
19 import androidx.compose.animation.core.Animatable
20 import androidx.compose.animation.core.VectorConverter
21 import androidx.compose.foundation.background
22 import androidx.compose.foundation.clickable
23 import androidx.compose.foundation.layout.Arrangement
24 import androidx.compose.foundation.layout.PaddingValues
25 import androidx.compose.foundation.layout.Row
26 import androidx.compose.foundation.layout.RowScope
27 import androidx.compose.foundation.layout.Spacer
28 import androidx.compose.foundation.layout.height
29 import androidx.compose.foundation.layout.offset
30 import androidx.compose.foundation.layout.padding
31 import androidx.compose.foundation.shape.CornerSize
32 import androidx.compose.foundation.shape.RoundedCornerShape
33 import androidx.compose.material3.LocalContentColor
34 import androidx.compose.material3.MaterialTheme
35 import androidx.compose.material3.TextButton
36 import androidx.compose.runtime.Composable
37 import androidx.compose.runtime.CompositionLocalProvider
38 import androidx.compose.runtime.remember
39 import androidx.compose.runtime.rememberCoroutineScope
40 import androidx.compose.ui.Alignment
41 import androidx.compose.ui.Modifier
42 import androidx.compose.ui.graphics.Color
43 import androidx.compose.ui.layout.Layout
44 import androidx.compose.ui.layout.Measurable
45 import androidx.compose.ui.layout.MeasurePolicy
46 import androidx.compose.ui.layout.MeasureResult
47 import androidx.compose.ui.layout.MeasureScope
48 import androidx.compose.ui.layout.Placeable
49 import androidx.compose.ui.layout.layoutId
50 import androidx.compose.ui.platform.LocalDensity
51 import androidx.compose.ui.semantics.Role
52 import androidx.compose.ui.semantics.clearAndSetSemantics
53 import androidx.compose.ui.semantics.contentDescription
54 import androidx.compose.ui.semantics.role
55 import androidx.compose.ui.semantics.selected
56 import androidx.compose.ui.semantics.semantics
57 import androidx.compose.ui.unit.Constraints
58 import androidx.compose.ui.unit.Dp
59 import androidx.compose.ui.unit.IntOffset
60 import androidx.compose.ui.unit.dp
61 import androidx.compose.ui.util.fastFirst
62 import kotlinx.coroutines.launch
63
64 /**
65 * Radio button group for the Volume Panel. It allows selecting a single item
66 *
67 * @param indicatorBackgroundPadding is the distance between the edge of the indicator and the
68 * indicator background
69 * @param labelIndicatorBackgroundSpacing is the distance between indicator background and labels
70 * row
71 */
72 @Composable
VolumePanelRadioButtonBarnull73 fun VolumePanelRadioButtonBar(
74 modifier: Modifier = Modifier,
75 indicatorBackgroundPadding: Dp =
76 VolumePanelRadioButtonBarDefaults.DefaultIndicatorBackgroundPadding,
77 spacing: Dp = VolumePanelRadioButtonBarDefaults.DefaultSpacing,
78 labelIndicatorBackgroundSpacing: Dp =
79 VolumePanelRadioButtonBarDefaults.DefaultLabelIndicatorBackgroundSpacing,
80 indicatorCornerSize: CornerSize =
81 CornerSize(VolumePanelRadioButtonBarDefaults.DefaultIndicatorCornerRadius),
82 indicatorBackgroundCornerSize: CornerSize =
83 CornerSize(VolumePanelRadioButtonBarDefaults.DefaultIndicatorBackgroundCornerRadius),
84 colors: VolumePanelRadioButtonBarColors = VolumePanelRadioButtonBarDefaults.defaultColors(),
85 content: VolumePanelRadioButtonBarScope.() -> Unit
86 ) {
87 val scope =
88 VolumePanelRadioButtonBarScopeImpl().apply(content).apply {
89 require(hasSelectedItem) { "At least one item should be selected" }
90 }
91 val items = scope.items
92
93 val coroutineScope = rememberCoroutineScope()
94 val offsetAnimatable = remember { Animatable(UNSET_OFFSET, Int.VectorConverter) }
95 Layout(
96 modifier = modifier,
97 content = {
98 Spacer(
99 modifier =
100 Modifier.layoutId(RadioButtonBarComponent.ButtonsBackground)
101 .background(
102 colors.indicatorBackgroundColor,
103 RoundedCornerShape(indicatorBackgroundCornerSize),
104 )
105 )
106 Spacer(
107 modifier =
108 Modifier.layoutId(RadioButtonBarComponent.Indicator)
109 .offset { IntOffset(offsetAnimatable.value, 0) }
110 .padding(indicatorBackgroundPadding)
111 .background(
112 colors.indicatorColor,
113 RoundedCornerShape(indicatorCornerSize),
114 )
115 )
116 Row(
117 modifier =
118 Modifier.layoutId(RadioButtonBarComponent.Buttons)
119 .padding(indicatorBackgroundPadding),
120 horizontalArrangement = Arrangement.spacedBy(spacing)
121 ) {
122 for (itemIndex in items.indices) {
123 val item = items[itemIndex]
124 val isSelected = itemIndex == scope.selectedIndex
125 Row(
126 modifier =
127 Modifier.height(48.dp)
128 .weight(1f)
129 .semantics {
130 item.contentDescription?.let { contentDescription = it }
131 role = Role.Switch
132 selected = isSelected
133 }
134 .clickable(
135 interactionSource = null,
136 indication = null,
137 onClick = { items[itemIndex].onItemSelected() }
138 ),
139 horizontalArrangement = Arrangement.Center,
140 verticalAlignment = Alignment.CenterVertically,
141 ) {
142 if (item.icon !== Empty) {
143 CompositionLocalProvider(
144 LocalContentColor provides colors.getIconColor(isSelected)
145 ) {
146 with(items[itemIndex]) { icon() }
147 }
148 }
149 }
150 }
151 }
152 Row(
153 modifier =
154 Modifier.layoutId(RadioButtonBarComponent.Labels)
155 .padding(
156 start = indicatorBackgroundPadding,
157 top = labelIndicatorBackgroundSpacing,
158 end = indicatorBackgroundPadding
159 )
160 .clearAndSetSemantics {},
161 horizontalArrangement = Arrangement.spacedBy(spacing),
162 ) {
163 for (itemIndex in items.indices) {
164 val cornersRadius = 4.dp
165 TextButton(
166 modifier = Modifier.weight(1f),
167 onClick = { items[itemIndex].onItemSelected() },
168 shape = RoundedCornerShape(cornersRadius),
169 contentPadding = PaddingValues(cornersRadius)
170 ) {
171 val item = items[itemIndex]
172 if (item.icon !== Empty) {
173 val textColor = colors.getLabelColor(itemIndex == scope.selectedIndex)
174 CompositionLocalProvider(LocalContentColor provides textColor) {
175 with(items[itemIndex]) { label() }
176 }
177 }
178 }
179 }
180 }
181 },
182 measurePolicy =
183 with(LocalDensity.current) {
184 val spacingPx =
185 (spacing - indicatorBackgroundPadding * 2).roundToPx().coerceAtLeast(0)
186
187 BarMeasurePolicy(
188 buttonsCount = items.size,
189 selectedIndex = scope.selectedIndex,
190 spacingPx = spacingPx,
191 ) {
192 coroutineScope.launch {
193 if (offsetAnimatable.value == UNSET_OFFSET) {
194 offsetAnimatable.snapTo(it)
195 } else {
196 offsetAnimatable.animateTo(it)
197 }
198 }
199 }
200 },
201 )
202 }
203
204 private class BarMeasurePolicy(
205 private val buttonsCount: Int,
206 private val selectedIndex: Int,
207 private val spacingPx: Int,
208 private val onTargetIndicatorOffsetMeasured: (Int) -> Unit,
209 ) : MeasurePolicy {
210
measurenull211 override fun MeasureScope.measure(
212 measurables: List<Measurable>,
213 constraints: Constraints
214 ): MeasureResult {
215 val fillWidthConstraints = constraints.copy(minWidth = constraints.maxWidth)
216 val buttonsPlaceable: Placeable =
217 measurables
218 .fastFirst { it.layoutId == RadioButtonBarComponent.Buttons }
219 .measure(fillWidthConstraints)
220 val labelsPlaceable: Placeable =
221 measurables
222 .fastFirst { it.layoutId == RadioButtonBarComponent.Labels }
223 .measure(fillWidthConstraints)
224
225 val buttonsBackgroundPlaceable: Placeable =
226 measurables
227 .fastFirst { it.layoutId == RadioButtonBarComponent.ButtonsBackground }
228 .measure(
229 Constraints(
230 minWidth = buttonsPlaceable.width,
231 maxWidth = buttonsPlaceable.width,
232 minHeight = buttonsPlaceable.height,
233 maxHeight = buttonsPlaceable.height,
234 )
235 )
236
237 val totalSpacing = spacingPx * (buttonsCount - 1)
238 val indicatorWidth = (buttonsBackgroundPlaceable.width - totalSpacing) / buttonsCount
239 val indicatorPlaceable: Placeable =
240 measurables
241 .fastFirst { it.layoutId == RadioButtonBarComponent.Indicator }
242 .measure(
243 Constraints(
244 minWidth = indicatorWidth,
245 maxWidth = indicatorWidth,
246 minHeight = buttonsBackgroundPlaceable.height,
247 maxHeight = buttonsBackgroundPlaceable.height,
248 )
249 )
250
251 onTargetIndicatorOffsetMeasured(
252 selectedIndex * indicatorWidth + (spacingPx * selectedIndex)
253 )
254
255 return layout(constraints.maxWidth, buttonsPlaceable.height + labelsPlaceable.height) {
256 buttonsBackgroundPlaceable.placeRelative(
257 0,
258 0,
259 RadioButtonBarComponent.ButtonsBackground.zIndex,
260 )
261 indicatorPlaceable.placeRelative(0, 0, RadioButtonBarComponent.Indicator.zIndex)
262
263 buttonsPlaceable.placeRelative(0, 0, RadioButtonBarComponent.Buttons.zIndex)
264 labelsPlaceable.placeRelative(
265 0,
266 buttonsBackgroundPlaceable.height,
267 RadioButtonBarComponent.Labels.zIndex,
268 )
269 }
270 }
271 }
272
273 data class VolumePanelRadioButtonBarColors(
274 /** Color of the indicator. */
275 val indicatorColor: Color,
276 /** Color of the indicator background. */
277 val indicatorBackgroundColor: Color,
278 /** Color of the icon. */
279 val iconColor: Color,
280 /** Color of the icon when it's selected. */
281 val selectedIconColor: Color,
282 /** Color of the label. */
283 val labelColor: Color,
284 /** Color of the label when it's selected. */
285 val selectedLabelColor: Color,
286 )
287
getIconColornull288 private fun VolumePanelRadioButtonBarColors.getIconColor(selected: Boolean): Color =
289 if (selected) selectedIconColor else iconColor
290
291 private fun VolumePanelRadioButtonBarColors.getLabelColor(selected: Boolean): Color =
292 if (selected) selectedLabelColor else labelColor
293
294 object VolumePanelRadioButtonBarDefaults {
295
296 val DefaultIndicatorBackgroundPadding = 8.dp
297 val DefaultSpacing = 24.dp
298 val DefaultLabelIndicatorBackgroundSpacing = 12.dp
299 val DefaultIndicatorCornerRadius = 20.dp
300 val DefaultIndicatorBackgroundCornerRadius = 28.dp
301
302 /**
303 * Returns the default VolumePanelRadioButtonBar colors.
304 *
305 * @param indicatorColor is the color of the indicator
306 * @param indicatorBackgroundColor is the color of the indicator background
307 */
308 @Composable
309 fun defaultColors(
310 indicatorColor: Color = MaterialTheme.colorScheme.tertiaryContainer,
311 indicatorBackgroundColor: Color = MaterialTheme.colorScheme.surface,
312 iconColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
313 selectedIconColor: Color = MaterialTheme.colorScheme.onTertiaryContainer,
314 labelColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
315 selectedLabelColor: Color = MaterialTheme.colorScheme.onSurface,
316 ): VolumePanelRadioButtonBarColors =
317 VolumePanelRadioButtonBarColors(
318 indicatorColor = indicatorColor,
319 indicatorBackgroundColor = indicatorBackgroundColor,
320 iconColor = iconColor,
321 selectedIconColor = selectedIconColor,
322 labelColor = labelColor,
323 selectedLabelColor = selectedLabelColor,
324 )
325 }
326
327 /** [VolumePanelRadioButtonBar] content scope. Use [item] to add more items. */
328 interface VolumePanelRadioButtonBarScope {
329
330 /**
331 * Adds a single item to the radio button group.
332 *
333 * @param isSelected true when the item is selected and false the otherwise
334 * @param onItemSelected is called when the item is selected
335 * @param icon of the to show in the indicator bar
336 * @param label to show below the indicator bar for the corresponding [icon]
337 */
itemnull338 fun item(
339 isSelected: Boolean,
340 onItemSelected: () -> Unit,
341 icon: @Composable RowScope.() -> Unit = Empty,
342 label: @Composable RowScope.() -> Unit = Empty,
343 contentDescription: String? = null,
344 )
345 }
346
347 private val Empty: @Composable RowScope.() -> Unit = {}
348
349 private class VolumePanelRadioButtonBarScopeImpl : VolumePanelRadioButtonBarScope {
350
351 var selectedIndex: Int = UNSET_INDEX
352 private set
353
354 val hasSelectedItem: Boolean
355 get() = selectedIndex != UNSET_INDEX
356
357 private val mutableItems: MutableList<Item> = mutableListOf()
358 val items: List<Item> = mutableItems
359
itemnull360 override fun item(
361 isSelected: Boolean,
362 onItemSelected: () -> Unit,
363 icon: @Composable RowScope.() -> Unit,
364 label: @Composable RowScope.() -> Unit,
365 contentDescription: String?,
366 ) {
367 require(!isSelected || !hasSelectedItem) { "Only one item should be selected at a time" }
368 if (isSelected) {
369 selectedIndex = mutableItems.size
370 }
371 mutableItems.add(
372 Item(
373 onItemSelected = onItemSelected,
374 icon = icon,
375 label = label,
376 contentDescription = contentDescription,
377 )
378 )
379 }
380
381 private companion object {
382 const val UNSET_INDEX = -1
383 }
384 }
385
386 private class Item(
387 val onItemSelected: () -> Unit,
388 val icon: @Composable RowScope.() -> Unit,
389 val label: @Composable RowScope.() -> Unit,
390 val contentDescription: String?,
391 )
392
393 private const val UNSET_OFFSET = -1
394
395 private enum class RadioButtonBarComponent(val zIndex: Float) {
396 ButtonsBackground(0f),
397 Indicator(1f),
398 Buttons(2f),
399 Labels(2f),
400 }
401