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