1 /*
<lambda>null2  * Copyright 2018 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 androidx.compose.material
18 
19 import androidx.compose.animation.animateColorAsState
20 import androidx.compose.animation.core.TweenSpec
21 import androidx.compose.foundation.Canvas
22 import androidx.compose.foundation.background
23 import androidx.compose.foundation.gestures.Orientation
24 import androidx.compose.foundation.indication
25 import androidx.compose.foundation.interaction.DragInteraction
26 import androidx.compose.foundation.interaction.Interaction
27 import androidx.compose.foundation.interaction.InteractionSource
28 import androidx.compose.foundation.interaction.MutableInteractionSource
29 import androidx.compose.foundation.interaction.PressInteraction
30 import androidx.compose.foundation.layout.Box
31 import androidx.compose.foundation.layout.BoxScope
32 import androidx.compose.foundation.layout.Spacer
33 import androidx.compose.foundation.layout.fillMaxSize
34 import androidx.compose.foundation.layout.offset
35 import androidx.compose.foundation.layout.padding
36 import androidx.compose.foundation.layout.requiredSize
37 import androidx.compose.foundation.layout.wrapContentSize
38 import androidx.compose.foundation.selection.toggleable
39 import androidx.compose.foundation.shape.CircleShape
40 import androidx.compose.runtime.Composable
41 import androidx.compose.runtime.Immutable
42 import androidx.compose.runtime.LaunchedEffect
43 import androidx.compose.runtime.Stable
44 import androidx.compose.runtime.State
45 import androidx.compose.runtime.getValue
46 import androidx.compose.runtime.mutableStateListOf
47 import androidx.compose.runtime.mutableStateOf
48 import androidx.compose.runtime.remember
49 import androidx.compose.runtime.rememberUpdatedState
50 import androidx.compose.runtime.setValue
51 import androidx.compose.runtime.snapshotFlow
52 import androidx.compose.ui.Alignment
53 import androidx.compose.ui.Modifier
54 import androidx.compose.ui.draw.shadow
55 import androidx.compose.ui.geometry.Offset
56 import androidx.compose.ui.graphics.Color
57 import androidx.compose.ui.graphics.StrokeCap
58 import androidx.compose.ui.graphics.compositeOver
59 import androidx.compose.ui.graphics.drawscope.DrawScope
60 import androidx.compose.ui.platform.LocalDensity
61 import androidx.compose.ui.platform.LocalLayoutDirection
62 import androidx.compose.ui.semantics.Role
63 import androidx.compose.ui.unit.IntOffset
64 import androidx.compose.ui.unit.LayoutDirection
65 import androidx.compose.ui.unit.dp
66 import kotlin.math.roundToInt
67 import kotlinx.coroutines.flow.collectLatest
68 
69 /**
70  * [Material Design switch](https://material.io/components/switches)
71  *
72  * Switches toggle the state of a single item on or off.
73  *
74  * ![Switches
75  * image](https://developer.android.com/images/reference/androidx/compose/material/switches.png)
76  *
77  * @sample androidx.compose.material.samples.SwitchSample
78  * @param checked whether or not this component is checked
79  * @param onCheckedChange callback to be invoked when Switch is being clicked, therefore the change
80  *   of checked state is requested. If null, then this is passive and relies entirely on a
81  *   higher-level component to control the "checked" state.
82  * @param modifier Modifier to be applied to the switch layout
83  * @param enabled whether the component is enabled or grayed out
84  * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
85  *   emitting [Interaction]s for this switch. You can use this to change the switch's appearance or
86  *   preview the switch in different states. Note that if `null` is provided, interactions will
87  *   still happen internally.
88  * @param colors [SwitchColors] that will be used to determine the color of the thumb and track in
89  *   different states. See [SwitchDefaults.colors].
90  */
91 @Composable
92 @OptIn(ExperimentalMaterialApi::class)
93 fun Switch(
94     checked: Boolean,
95     onCheckedChange: ((Boolean) -> Unit)?,
96     modifier: Modifier = Modifier,
97     enabled: Boolean = true,
98     interactionSource: MutableInteractionSource? = null,
99     colors: SwitchColors = SwitchDefaults.colors()
100 ) {
101     @Suppress("NAME_SHADOWING")
102     val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
103     val minBound = 0f
104     val maxBound = with(LocalDensity.current) { ThumbPathLength.toPx() }
105     // If we reach a bound and settle, we invoke onCheckedChange with the new value. If the user
106     // does not update `checked`, we would now be in an invalid state. We keep track of the
107     // the animation state through this, animating back to the previous value if we don't receive
108     // a new checked value.
109     var forceAnimationCheck by remember { mutableStateOf(false) }
110     val switchVelocityThresholdPx = with(LocalDensity.current) { SwitchVelocityThreshold.toPx() }
111     val anchoredDraggableState =
112         remember(maxBound, switchVelocityThresholdPx) {
113             AnchoredDraggableState(
114                 initialValue = checked,
115                 animationSpec = AnimationSpec,
116                 anchors =
117                     DraggableAnchors {
118                         false at minBound
119                         true at maxBound
120                     },
121                 positionalThreshold = { distance -> distance * SwitchPositionalThreshold },
122                 velocityThreshold = { switchVelocityThresholdPx }
123             )
124         }
125     val currentOnCheckedChange by rememberUpdatedState(onCheckedChange)
126     val currentChecked by rememberUpdatedState(checked)
127     LaunchedEffect(anchoredDraggableState) {
128         snapshotFlow { anchoredDraggableState.currentValue }
129             .collectLatest { newValue ->
130                 if (currentChecked != newValue) {
131                     currentOnCheckedChange?.invoke(newValue)
132                     forceAnimationCheck = !forceAnimationCheck
133                 }
134             }
135     }
136     LaunchedEffect(checked, forceAnimationCheck) {
137         if (checked != anchoredDraggableState.currentValue) {
138             anchoredDraggableState.animateTo(checked)
139         }
140     }
141     val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
142     val toggleableModifier =
143         if (onCheckedChange != null) {
144             Modifier.toggleable(
145                 value = checked,
146                 onValueChange = onCheckedChange,
147                 enabled = enabled,
148                 role = Role.Switch,
149                 interactionSource = interactionSource,
150                 indication = null
151             )
152         } else {
153             Modifier
154         }
155 
156     Box(
157         modifier
158             .then(
159                 if (onCheckedChange != null) {
160                     Modifier.minimumInteractiveComponentSize()
161                 } else {
162                     Modifier
163                 }
164             )
165             .then(toggleableModifier)
166             .anchoredDraggable(
167                 state = anchoredDraggableState,
168                 orientation = Orientation.Horizontal,
169                 enabled = enabled && onCheckedChange != null,
170                 reverseDirection = isRtl,
171                 interactionSource = interactionSource,
172                 startDragImmediately = false
173             )
174             .wrapContentSize(Alignment.Center)
175             .padding(DefaultSwitchPadding)
176             .requiredSize(SwitchWidth, SwitchHeight)
177     ) {
178         SwitchImpl(
179             checked = anchoredDraggableState.targetValue,
180             enabled = enabled,
181             colors = colors,
182             thumbValue = { anchoredDraggableState.requireOffset() },
183             interactionSource = interactionSource
184         )
185     }
186 }
187 
188 /**
189  * Represents the colors used by a [Switch] in different states
190  *
191  * See [SwitchDefaults.colors] for the default implementation that follows Material specifications.
192  */
193 @Stable
194 interface SwitchColors {
195 
196     /**
197      * Represents the color used for the switch's thumb, depending on [enabled] and [checked].
198      *
199      * @param enabled whether the [Switch] is enabled or not
200      * @param checked whether the [Switch] is checked or not
201      */
thumbColornull202     @Composable fun thumbColor(enabled: Boolean, checked: Boolean): State<Color>
203 
204     /**
205      * Represents the color used for the switch's track, depending on [enabled] and [checked].
206      *
207      * @param enabled whether the [Switch] is enabled or not
208      * @param checked whether the [Switch] is checked or not
209      */
210     @Composable fun trackColor(enabled: Boolean, checked: Boolean): State<Color>
211 }
212 
213 @Composable
214 private fun BoxScope.SwitchImpl(
215     checked: Boolean,
216     enabled: Boolean,
217     colors: SwitchColors,
218     thumbValue: () -> Float,
219     interactionSource: InteractionSource
220 ) {
221     val interactions = remember { mutableStateListOf<Interaction>() }
222 
223     LaunchedEffect(interactionSource) {
224         interactionSource.interactions.collect { interaction ->
225             when (interaction) {
226                 is PressInteraction.Press -> interactions.add(interaction)
227                 is PressInteraction.Release -> interactions.remove(interaction.press)
228                 is PressInteraction.Cancel -> interactions.remove(interaction.press)
229                 is DragInteraction.Start -> interactions.add(interaction)
230                 is DragInteraction.Stop -> interactions.remove(interaction.start)
231                 is DragInteraction.Cancel -> interactions.remove(interaction.start)
232             }
233         }
234     }
235 
236     val hasInteraction = interactions.isNotEmpty()
237     val elevation =
238         if (hasInteraction) {
239             ThumbPressedElevation
240         } else {
241             ThumbDefaultElevation
242         }
243     val trackColor by colors.trackColor(enabled, checked)
244     Canvas(Modifier.align(Alignment.Center).fillMaxSize()) {
245         drawTrack(trackColor, TrackWidth.toPx(), TrackStrokeWidth.toPx())
246     }
247     val thumbColor by colors.thumbColor(enabled, checked)
248     val elevationOverlay = LocalElevationOverlay.current
249     val absoluteElevation = LocalAbsoluteElevation.current + elevation
250     val resolvedThumbColor by
251         animateColorAsState(
252             if (thumbColor == MaterialTheme.colors.surface && elevationOverlay != null) {
253                 elevationOverlay.apply(thumbColor, absoluteElevation)
254             } else {
255                 thumbColor
256             }
257         )
258     Spacer(
259         Modifier.align(Alignment.CenterStart)
260             .offset { IntOffset(thumbValue().roundToInt(), 0) }
261             .indication(
262                 interactionSource = interactionSource,
263                 indication = ripple(bounded = false, radius = ThumbRippleRadius)
264             )
265             .requiredSize(ThumbDiameter)
266             .shadow(elevation, CircleShape, clip = false)
267             .background(resolvedThumbColor, CircleShape)
268     )
269 }
270 
DrawScopenull271 private fun DrawScope.drawTrack(trackColor: Color, trackWidth: Float, strokeWidth: Float) {
272     val strokeRadius = strokeWidth / 2
273     drawLine(
274         trackColor,
275         Offset(strokeRadius, center.y),
276         Offset(trackWidth - strokeRadius, center.y),
277         strokeWidth,
278         StrokeCap.Round
279     )
280 }
281 
282 internal val TrackWidth = 34.dp
283 internal val TrackStrokeWidth = 14.dp
284 internal val ThumbDiameter = 20.dp
285 
286 private val ThumbRippleRadius = 24.dp
287 
288 private val DefaultSwitchPadding = 2.dp
289 private val SwitchWidth = TrackWidth
290 private val SwitchHeight = ThumbDiameter
291 private val ThumbPathLength = TrackWidth - ThumbDiameter
292 
293 private val AnimationSpec = TweenSpec<Float>(durationMillis = 100)
294 
295 private val ThumbDefaultElevation = 1.dp
296 private val ThumbPressedElevation = 6.dp
297 
298 /** Contains the default values used by [Switch] */
299 object SwitchDefaults {
300     /**
301      * Creates a [SwitchColors] that represents the different colors used in a [Switch] in different
302      * states.
303      *
304      * @param checkedThumbColor the color used for the thumb when enabled and checked
305      * @param checkedTrackColor the color used for the track when enabled and checked
306      * @param checkedTrackAlpha the alpha applied to [checkedTrackColor] and
307      *   [disabledCheckedTrackColor]
308      * @param uncheckedThumbColor the color used for the thumb when enabled and unchecked
309      * @param uncheckedTrackColor the color used for the track when enabled and unchecked
310      * @param uncheckedTrackAlpha the alpha applied to [uncheckedTrackColor] and
311      *   [disabledUncheckedTrackColor]
312      * @param disabledCheckedThumbColor the color used for the thumb when disabled and checked
313      * @param disabledCheckedTrackColor the color used for the track when disabled and checked
314      * @param disabledUncheckedThumbColor the color used for the thumb when disabled and unchecked
315      * @param disabledUncheckedTrackColor the color used for the track when disabled and unchecked
316      */
317     @Composable
colorsnull318     fun colors(
319         checkedThumbColor: Color = MaterialTheme.colors.secondaryVariant,
320         checkedTrackColor: Color = checkedThumbColor,
321         checkedTrackAlpha: Float = 0.54f,
322         uncheckedThumbColor: Color = MaterialTheme.colors.surface,
323         uncheckedTrackColor: Color = MaterialTheme.colors.onSurface,
324         uncheckedTrackAlpha: Float = 0.38f,
325         disabledCheckedThumbColor: Color =
326             checkedThumbColor
327                 .copy(alpha = ContentAlpha.disabled)
328                 .compositeOver(MaterialTheme.colors.surface),
329         disabledCheckedTrackColor: Color =
330             checkedTrackColor
331                 .copy(alpha = ContentAlpha.disabled)
332                 .compositeOver(MaterialTheme.colors.surface),
333         disabledUncheckedThumbColor: Color =
334             uncheckedThumbColor
335                 .copy(alpha = ContentAlpha.disabled)
336                 .compositeOver(MaterialTheme.colors.surface),
337         disabledUncheckedTrackColor: Color =
338             uncheckedTrackColor
339                 .copy(alpha = ContentAlpha.disabled)
340                 .compositeOver(MaterialTheme.colors.surface)
341     ): SwitchColors =
342         DefaultSwitchColors(
343             checkedThumbColor = checkedThumbColor,
344             checkedTrackColor = checkedTrackColor.copy(alpha = checkedTrackAlpha),
345             uncheckedThumbColor = uncheckedThumbColor,
346             uncheckedTrackColor = uncheckedTrackColor.copy(alpha = uncheckedTrackAlpha),
347             disabledCheckedThumbColor = disabledCheckedThumbColor,
348             disabledCheckedTrackColor = disabledCheckedTrackColor.copy(alpha = checkedTrackAlpha),
349             disabledUncheckedThumbColor = disabledUncheckedThumbColor,
350             disabledUncheckedTrackColor =
351                 disabledUncheckedTrackColor.copy(alpha = uncheckedTrackAlpha)
352         )
353 }
354 
355 /** Default [SwitchColors] implementation. */
356 @Immutable
357 private class DefaultSwitchColors(
358     private val checkedThumbColor: Color,
359     private val checkedTrackColor: Color,
360     private val uncheckedThumbColor: Color,
361     private val uncheckedTrackColor: Color,
362     private val disabledCheckedThumbColor: Color,
363     private val disabledCheckedTrackColor: Color,
364     private val disabledUncheckedThumbColor: Color,
365     private val disabledUncheckedTrackColor: Color
366 ) : SwitchColors {
367     @Composable
368     override fun thumbColor(enabled: Boolean, checked: Boolean): State<Color> {
369         return rememberUpdatedState(
370             if (enabled) {
371                 if (checked) checkedThumbColor else uncheckedThumbColor
372             } else {
373                 if (checked) disabledCheckedThumbColor else disabledUncheckedThumbColor
374             }
375         )
376     }
377 
378     @Composable
379     override fun trackColor(enabled: Boolean, checked: Boolean): State<Color> {
380         return rememberUpdatedState(
381             if (enabled) {
382                 if (checked) checkedTrackColor else uncheckedTrackColor
383             } else {
384                 if (checked) disabledCheckedTrackColor else disabledUncheckedTrackColor
385             }
386         )
387     }
388 
389     override fun equals(other: Any?): Boolean {
390         if (this === other) return true
391         if (other == null || this::class != other::class) return false
392 
393         other as DefaultSwitchColors
394 
395         if (checkedThumbColor != other.checkedThumbColor) return false
396         if (checkedTrackColor != other.checkedTrackColor) return false
397         if (uncheckedThumbColor != other.uncheckedThumbColor) return false
398         if (uncheckedTrackColor != other.uncheckedTrackColor) return false
399         if (disabledCheckedThumbColor != other.disabledCheckedThumbColor) return false
400         if (disabledCheckedTrackColor != other.disabledCheckedTrackColor) return false
401         if (disabledUncheckedThumbColor != other.disabledUncheckedThumbColor) return false
402         if (disabledUncheckedTrackColor != other.disabledUncheckedTrackColor) return false
403 
404         return true
405     }
406 
407     override fun hashCode(): Int {
408         var result = checkedThumbColor.hashCode()
409         result = 31 * result + checkedTrackColor.hashCode()
410         result = 31 * result + uncheckedThumbColor.hashCode()
411         result = 31 * result + uncheckedTrackColor.hashCode()
412         result = 31 * result + disabledCheckedThumbColor.hashCode()
413         result = 31 * result + disabledCheckedTrackColor.hashCode()
414         result = 31 * result + disabledUncheckedThumbColor.hashCode()
415         result = 31 * result + disabledUncheckedTrackColor.hashCode()
416         return result
417     }
418 }
419 
420 private const val SwitchPositionalThreshold = 0.7f
421 private val SwitchVelocityThreshold = 125.dp
422