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 * 
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