• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2025 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.dialog.sliders.ui.compose
18 
19 import androidx.compose.foundation.layout.Box
20 import androidx.compose.foundation.layout.BoxScope
21 import androidx.compose.foundation.layout.fillMaxSize
22 import androidx.compose.foundation.layout.height
23 import androidx.compose.foundation.layout.width
24 import androidx.compose.material3.ExperimentalMaterial3Api
25 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
26 import androidx.compose.material3.LocalContentColor
27 import androidx.compose.material3.SliderColors
28 import androidx.compose.material3.SliderDefaults
29 import androidx.compose.material3.SliderState
30 import androidx.compose.runtime.Composable
31 import androidx.compose.runtime.CompositionLocalProvider
32 import androidx.compose.runtime.MutableState
33 import androidx.compose.runtime.mutableStateOf
34 import androidx.compose.runtime.remember
35 import androidx.compose.ui.Modifier
36 import androidx.compose.ui.layout.Layout
37 import androidx.compose.ui.layout.Measurable
38 import androidx.compose.ui.layout.MeasurePolicy
39 import androidx.compose.ui.layout.MeasureResult
40 import androidx.compose.ui.layout.MeasureScope
41 import androidx.compose.ui.layout.layoutId
42 import androidx.compose.ui.platform.LocalLayoutDirection
43 import androidx.compose.ui.unit.Constraints
44 import androidx.compose.ui.unit.Dp
45 import androidx.compose.ui.unit.LayoutDirection
46 import androidx.compose.ui.unit.dp
47 import androidx.compose.ui.util.fastFirst
48 import kotlin.math.min
49 
50 @Composable
51 @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
52 fun SliderTrack(
53     sliderState: SliderState,
54     isEnabled: Boolean,
55     modifier: Modifier = Modifier,
56     colors: SliderColors = SliderDefaults.colors(),
57     thumbTrackGapSize: Dp = 6.dp,
58     trackCornerSize: Dp = 12.dp,
59     trackInsideCornerSize: Dp = 2.dp,
60     trackSize: Dp = 40.dp,
61     isVertical: Boolean = false,
62     activeTrackStartIcon: (@Composable BoxScope.(iconsState: SliderIconsState) -> Unit)? = null,
63     activeTrackEndIcon: (@Composable BoxScope.(iconsState: SliderIconsState) -> Unit)? = null,
64     inactiveTrackStartIcon: (@Composable BoxScope.(iconsState: SliderIconsState) -> Unit)? = null,
65     inactiveTrackEndIcon: (@Composable BoxScope.(iconsState: SliderIconsState) -> Unit)? = null,
66 ) {
67     val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
68     val measurePolicy =
69         remember(sliderState) {
70             TrackMeasurePolicy(
71                 sliderState = sliderState,
72                 shouldMirrorIcons = !isVertical && isRtl || isVertical,
73                 isVertical = isVertical,
74                 gapSize = thumbTrackGapSize,
75             )
76         }
77     Layout(
78         measurePolicy = measurePolicy,
79         content = {
80             SliderDefaults.Track(
81                 sliderState = sliderState,
82                 colors = colors,
83                 enabled = isEnabled,
84                 trackCornerSize = trackCornerSize,
85                 trackInsideCornerSize = trackInsideCornerSize,
86                 drawStopIndicator = null,
87                 thumbTrackGapSize = thumbTrackGapSize,
88                 drawTick = { _, _ -> },
89                 modifier =
90                     Modifier.then(
91                             if (isVertical) {
92                                 Modifier.width(trackSize)
93                             } else {
94                                 Modifier.height(trackSize)
95                             }
96                         )
97                         .layoutId(Contents.Track),
98             )
99 
100             TrackIcon(
101                 icon = activeTrackStartIcon,
102                 contents = Contents.Active.TrackStartIcon,
103                 isEnabled = isEnabled,
104                 colors = colors,
105                 state = measurePolicy,
106             )
107             TrackIcon(
108                 icon = activeTrackEndIcon,
109                 contents = Contents.Active.TrackEndIcon,
110                 isEnabled = isEnabled,
111                 colors = colors,
112                 state = measurePolicy,
113             )
114             TrackIcon(
115                 icon = inactiveTrackStartIcon,
116                 contents = Contents.Inactive.TrackStartIcon,
117                 isEnabled = isEnabled,
118                 colors = colors,
119                 state = measurePolicy,
120             )
121             TrackIcon(
122                 icon = inactiveTrackEndIcon,
123                 contents = Contents.Inactive.TrackEndIcon,
124                 isEnabled = isEnabled,
125                 colors = colors,
126                 state = measurePolicy,
127             )
128         },
129         modifier = modifier,
130     )
131 }
132 
133 @Composable
TrackIconnull134 private fun TrackIcon(
135     icon: (@Composable BoxScope.(sliderIconsState: SliderIconsState) -> Unit)?,
136     isEnabled: Boolean,
137     contents: Contents,
138     state: SliderIconsState,
139     colors: SliderColors,
140     modifier: Modifier = Modifier,
141 ) {
142     icon ?: return
143     /*
144     ignore icons mirroring for the rtl layouts here because icons positioning is handled by the
145     TrackMeasurePolicy. It ensures that active icons are always above the active track and the
146     same for inactive
147     */
148     val iconColor =
149         when (contents) {
150             is Contents.Inactive ->
151                 if (isEnabled) {
152                     colors.inactiveTickColor
153                 } else {
154                     colors.disabledInactiveTickColor
155                 }
156             is Contents.Active ->
157                 if (isEnabled) {
158                     colors.activeTickColor
159                 } else {
160                     colors.disabledActiveTickColor
161                 }
162             is Contents.Track -> {
163                 error("$contents is unsupported by the TrackIcon")
164             }
165         }
166     Box(modifier = modifier.layoutId(contents).fillMaxSize()) {
167         CompositionLocalProvider(LocalContentColor provides iconColor) { icon(state) }
168     }
169 }
170 
171 @OptIn(ExperimentalMaterial3Api::class)
172 private class TrackMeasurePolicy(
173     private val sliderState: SliderState,
174     private val shouldMirrorIcons: Boolean,
175     private val gapSize: Dp,
176     private val isVertical: Boolean,
177 ) : MeasurePolicy, SliderIconsState {
178 
179     private val isVisible: Map<Contents, MutableState<Boolean>> =
180         mutableMapOf(
181             Contents.Active.TrackStartIcon to mutableStateOf(false),
182             Contents.Active.TrackEndIcon to mutableStateOf(false),
183             Contents.Inactive.TrackStartIcon to mutableStateOf(false),
184             Contents.Inactive.TrackEndIcon to mutableStateOf(false),
185         )
186 
187     override val isActiveTrackStartIconVisible: Boolean
188         get() = isVisible.getValue(Contents.Active.TrackStartIcon.resolve()).value
189 
190     override val isActiveTrackEndIconVisible: Boolean
191         get() = isVisible.getValue(Contents.Active.TrackEndIcon.resolve()).value
192 
193     override val isInactiveTrackStartIconVisible: Boolean
194         get() = isVisible.getValue(Contents.Inactive.TrackStartIcon.resolve()).value
195 
196     override val isInactiveTrackEndIconVisible: Boolean
197         get() = isVisible.getValue(Contents.Inactive.TrackEndIcon.resolve()).value
198 
measurenull199     override fun MeasureScope.measure(
200         measurables: List<Measurable>,
201         constraints: Constraints,
202     ): MeasureResult {
203         val track = measurables.fastFirst { it.layoutId == Contents.Track }.measure(constraints)
204 
205         val iconSize = min(track.width, track.height)
206         val iconConstraints = constraints.copy(maxWidth = iconSize, maxHeight = iconSize)
207 
208         val components = buildMap {
209             put(Contents.Track, track)
210             for (measurable in measurables) {
211                 // don't measure track a second time
212                 if (measurable.layoutId != Contents.Track) {
213                     put(
214                         (measurable.layoutId as Contents).resolve(),
215                         measurable.measure(iconConstraints),
216                     )
217                 }
218             }
219         }
220 
221         return layout(track.width, track.height) {
222             val gapSizePx = gapSize.roundToPx()
223             val coercedValueAsFraction =
224                 if (shouldMirrorIcons) {
225                     1 - sliderState.coercedValueAsFraction
226                 } else {
227                     sliderState.coercedValueAsFraction
228                 }
229             for (iconLayoutId in components.keys) {
230                 val iconPlaceable = components.getValue(iconLayoutId)
231                 if (isVertical) {
232                     iconPlaceable.place(
233                         0,
234                         iconLayoutId.calculatePosition(
235                             placeableDimension = iconPlaceable.height,
236                             containerDimension = track.height,
237                             gapSize = gapSizePx,
238                             coercedValueAsFraction = coercedValueAsFraction,
239                         ),
240                     )
241                 } else {
242                     iconPlaceable.place(
243                         iconLayoutId.calculatePosition(
244                             placeableDimension = iconPlaceable.width,
245                             containerDimension = track.width,
246                             gapSize = gapSizePx,
247                             coercedValueAsFraction = coercedValueAsFraction,
248                         ),
249                         0,
250                     )
251                 }
252 
253                 // isVisible is only relevant for the icons
254                 if (iconLayoutId != Contents.Track) {
255                     val isVisibleState = isVisible.getValue(iconLayoutId)
256                     val newIsVisible =
257                         iconLayoutId.isVisible(
258                             placeableDimension =
259                                 if (isVertical) iconPlaceable.height else iconPlaceable.width,
260                             containerDimension = if (isVertical) track.height else track.width,
261                             gapSize = gapSizePx,
262                             coercedValueAsFraction = coercedValueAsFraction,
263                         )
264                     if (isVisibleState.value != newIsVisible) {
265                         isVisibleState.value = newIsVisible
266                     }
267                 }
268             }
269         }
270     }
271 
Contentsnull272     private fun Contents.resolve(): Contents {
273         return if (shouldMirrorIcons) {
274             mirrored
275         } else {
276             this
277         }
278     }
279 }
280 
281 private sealed interface Contents {
282 
283     data object Track : Contents {
284 
285         override val mirrored: Contents
286             get() = error("unsupported for Track")
287 
calculatePositionnull288         override fun calculatePosition(
289             placeableDimension: Int,
290             containerDimension: Int,
291             gapSize: Int,
292             coercedValueAsFraction: Float,
293         ): Int = 0
294 
295         override fun isVisible(
296             placeableDimension: Int,
297             containerDimension: Int,
298             gapSize: Int,
299             coercedValueAsFraction: Float,
300         ): Boolean = true
301     }
302 
303     interface Active : Contents {
304 
305         override fun isVisible(
306             placeableDimension: Int,
307             containerDimension: Int,
308             gapSize: Int,
309             coercedValueAsFraction: Float,
310         ): Boolean =
311             (containerDimension * coercedValueAsFraction - gapSize).toInt() > placeableDimension
312 
313         data object TrackStartIcon : Active {
314 
315             override val mirrored: Contents
316                 get() = Inactive.TrackEndIcon
317 
318             override fun calculatePosition(
319                 placeableDimension: Int,
320                 containerDimension: Int,
321                 gapSize: Int,
322                 coercedValueAsFraction: Float,
323             ): Int = 0
324         }
325 
326         data object TrackEndIcon : Active {
327 
328             override val mirrored: Contents
329                 get() = Inactive.TrackStartIcon
330 
331             override fun calculatePosition(
332                 placeableDimension: Int,
333                 containerDimension: Int,
334                 gapSize: Int,
335                 coercedValueAsFraction: Float,
336             ): Int =
337                 (containerDimension * coercedValueAsFraction - placeableDimension - gapSize).toInt()
338         }
339     }
340 
341     interface Inactive : Contents {
342 
isVisiblenull343         override fun isVisible(
344             placeableDimension: Int,
345             containerDimension: Int,
346             gapSize: Int,
347             coercedValueAsFraction: Float,
348         ): Boolean =
349             containerDimension - (containerDimension * coercedValueAsFraction + gapSize) >
350                 placeableDimension
351 
352         data object TrackStartIcon : Inactive {
353 
354             override val mirrored: Contents
355                 get() = Active.TrackEndIcon
356 
357             override fun calculatePosition(
358                 placeableDimension: Int,
359                 containerDimension: Int,
360                 gapSize: Int,
361                 coercedValueAsFraction: Float,
362             ): Int = (containerDimension * coercedValueAsFraction + gapSize).toInt()
363         }
364 
365         data object TrackEndIcon : Inactive {
366 
367             override val mirrored: Contents
368                 get() = Active.TrackStartIcon
369 
calculatePositionnull370             override fun calculatePosition(
371                 placeableDimension: Int,
372                 containerDimension: Int,
373                 gapSize: Int,
374                 coercedValueAsFraction: Float,
375             ): Int = containerDimension - placeableDimension
376         }
377     }
378 
379     fun calculatePosition(
380         placeableDimension: Int,
381         containerDimension: Int,
382         gapSize: Int,
383         coercedValueAsFraction: Float,
384     ): Int
385 
386     fun isVisible(
387         placeableDimension: Int,
388         containerDimension: Int,
389         gapSize: Int,
390         coercedValueAsFraction: Float,
391     ): Boolean
392 
393     /**
394      * [Contents] that is visually on the opposite side of the current one on the slider. This is
395      * handy when dealing with the rtl layouts
396      */
397     val mirrored: Contents
398 }
399 
400 /** Provides visibility state for each of the Slider's icons. */
401 interface SliderIconsState {
402     val isActiveTrackStartIconVisible: Boolean
403     val isActiveTrackEndIconVisible: Boolean
404     val isInactiveTrackStartIconVisible: Boolean
405     val isInactiveTrackEndIconVisible: Boolean
406 }
407