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