Update switch style to match settings

This commit is contained in:
Suphon Thanakornpakapong
2022-05-10 10:17:47 +07:00
parent 8edee5bbed
commit da56f42031
5 changed files with 478 additions and 5 deletions
@@ -11,6 +11,7 @@ object ColorTokens {
val Neutral1_50 = SwatchColorToken(Swatch.Neutral1, Shade.S50)
val Neutral1_100 = SwatchColorToken(Swatch.Neutral1, Shade.S100)
val Neutral1_200 = SwatchColorToken(Swatch.Neutral1, Shade.S200)
val Neutral1_400 = SwatchColorToken(Swatch.Neutral1, Shade.S400)
val Neutral1_500 = SwatchColorToken(Swatch.Neutral1, Shade.S500)
val Neutral1_700 = SwatchColorToken(Swatch.Neutral1, Shade.S700)
val Neutral1_800 = SwatchColorToken(Swatch.Neutral1, Shade.S800)
@@ -33,6 +34,7 @@ object ColorTokens {
val Accent2_50 = SwatchColorToken(Swatch.Accent2, Shade.S50)
val Accent2_100 = SwatchColorToken(Swatch.Accent2, Shade.S100)
val Accent2_500 = SwatchColorToken(Swatch.Accent2, Shade.S500)
val Accent2_600 = SwatchColorToken(Swatch.Accent2, Shade.S600)
val Accent3_50 = SwatchColorToken(Swatch.Accent3, Shade.S50)
@@ -86,6 +88,13 @@ object ColorTokens {
@JvmField val WidgetsPickerScrim = DayNightColorToken(Neutral1_200, Neutral1_900).setAlpha(0.8f)
@JvmField val WorkspaceAccentColor = DarkTextColorToken(Accent1_100, Accent2_600)
val SwitchThumbOn = Accent1_100
val SwitchThumbOff = DayNightColorToken(Neutral2_300, Neutral1_400)
val SwitchThumbDisabled = DayNightColorToken(Neutral2_100, Neutral1_700)
val SwitchTrackOn = DayNightColorToken(Accent1_600, Accent2_500.setLStar(51.0))
val SwitchTrackOff = DayNightColorToken(Neutral2_500.setLStar(45.0), Neutral1_700)
}
@Composable
@@ -0,0 +1,95 @@
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package app.lawnchair.ui
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.layout.LayoutModifier
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.DpSize
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterialApi::class)
@Suppress("ModifierInspectorInfo")
internal fun Modifier.minimumTouchTargetSize(): Modifier = composed(
inspectorInfo = debugInspectorInfo {
name = "minimumTouchTargetSize"
// TODO: b/214589635 - surface this information through the layout inspector in a better way
// - for now just add some information to help developers debug what this size represents.
properties["README"] = "Adds outer padding to measure at least 48.dp (default) in " +
"size to disambiguate touch interactions if the element would measure smaller"
}
) {
if (LocalMinimumTouchTargetEnforcement.current) {
// TODO: consider using a hardcoded value of 48.dp instead to avoid inconsistent UI if the
// LocalViewConfiguration changes across devices / during runtime.
val size = LocalViewConfiguration.current.minimumTouchTargetSize
MinimumTouchTargetModifier(size)
} else {
Modifier
}
}
/**
* CompositionLocal that configures whether Material components that have a visual size that is
* lower than the minimum touch target size for accessibility (such as [Button]) will include
* extra space outside the component to ensure that they are accessible. If set to false there
* will be no extra space, and so it is possible that if the component is placed near the edge of
* a layout / near to another component without any padding, there will not be enough space for
* an accessible touch target.
*/
@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
@ExperimentalMaterialApi
val LocalMinimumTouchTargetEnforcement: ProvidableCompositionLocal<Boolean> =
staticCompositionLocalOf { true }
private class MinimumTouchTargetModifier(val size: DpSize) : LayoutModifier {
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureResult {
val placeable = measurable.measure(constraints)
// Be at least as big as the minimum dimension in both dimensions
val width = maxOf(placeable.width, size.width.roundToPx())
val height = maxOf(placeable.height, size.height.roundToPx())
return layout(width, height) {
val centerX = ((width - placeable.width) / 2f).roundToInt()
val centerY = ((height - placeable.height) / 2f).roundToInt()
placeable.place(centerX, centerY)
}
}
override fun equals(other: Any?): Boolean {
val otherModifier = other as? MinimumTouchTargetModifier ?: return false
return size == otherModifier.size
}
override fun hashCode(): Int {
return size.hashCode()
}
}
@@ -0,0 +1,372 @@
/*
* Copyright 2018 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package app.lawnchair.ui.preferences.components
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.TweenSpec
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.indication
import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import app.lawnchair.theme.UiColorMode
import app.lawnchair.theme.color.ColorTokens
import app.lawnchair.ui.minimumTouchTargetSize
import dev.kdrag0n.monet.theme.toComposeColor
import kotlin.math.roundToInt
/**
* <a href="https://material.io/components/switches" class="external" target="_blank">Material Design switch</a>.
*
* Switches toggle the state of a single item on or off.
*
* ![Switches image](https://developer.android.com/images/reference/androidx/compose/material/switches.png)
*
* @param checked whether or not this component is checked
* @param onCheckedChange callback to be invoked when Switch is being clicked,
* therefore the change of checked state is requested. If null, then this is passive
* and relies entirely on a higher-level component to control the "checked" state.
* @param modifier Modifier to be applied to the switch layout
* @param enabled whether the component is enabled or grayed out
* @param interactionSource the [MutableInteractionSource] representing the stream of
* [Interaction]s for this Switch. You can create and pass in your own remembered
* [MutableInteractionSource] if you want to observe [Interaction]s and customize the
* appearance / behavior of this Switch in different [Interaction]s.
* @param colors [SwitchColors] that will be used to determine the color of the thumb and track
* in different states. See [SwitchDefaults.colors].
*/
@Composable
@OptIn(ExperimentalMaterialApi::class)
fun MYSwitch(
checked: Boolean,
onCheckedChange: ((Boolean) -> Unit)?,
modifier: Modifier = Modifier,
enabled: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
colors: SwitchColors = SwitchDefaults.colors()
) {
val minBound = with(LocalDensity.current) { ThumbInset.toPx() }
val maxBound = with(LocalDensity.current) { (ThumbPathLength - ThumbInset).toPx() }
val swipeableState = rememberSwipeableStateFor(checked, onCheckedChange ?: {}, AnimationSpec)
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
val toggleableModifier =
if (onCheckedChange != null) {
Modifier.toggleable(
value = checked,
onValueChange = onCheckedChange,
enabled = enabled,
role = Role.Switch,
interactionSource = interactionSource,
indication = null
)
} else {
Modifier
}
Box(
modifier
.then(
if (onCheckedChange != null) Modifier.minimumTouchTargetSize() else Modifier
)
.then(toggleableModifier)
.swipeable(
state = swipeableState,
anchors = mapOf(minBound to false, maxBound to true),
thresholds = { _, _ -> FractionalThreshold(0.5f) },
orientation = Orientation.Horizontal,
enabled = enabled && onCheckedChange != null,
reverseDirection = isRtl,
interactionSource = interactionSource,
resistance = null
)
.wrapContentSize(Alignment.Center)
.padding(DefaultSwitchPadding)
.requiredSize(SwitchWidth, SwitchHeight)
) {
MYSwitchImpl(
checked = checked,
enabled = enabled,
colors = colors,
thumbValue = swipeableState.offset,
interactionSource = interactionSource
)
}
}
/**
* Represents the colors used by a [Switch] in different states
*
* See [SwitchDefaults.colors] for the default implementation that follows Material
* specifications.
*/
@Stable
interface SwitchColors {
/**
* Represents the color used for the switch's thumb, depending on [enabled] and [checked].
*
* @param enabled whether the [Switch] is enabled or not
* @param checked whether the [Switch] is checked or not
*/
@Composable
fun thumbColor(enabled: Boolean, checked: Boolean): State<Color>
/**
* Represents the color used for the switch's track, depending on [enabled] and [checked].
*
* @param enabled whether the [Switch] is enabled or not
* @param checked whether the [Switch] is checked or not
*/
@Composable
fun trackColor(enabled: Boolean, checked: Boolean): State<Color>
}
@Composable
private fun BoxScope.MYSwitchImpl(
checked: Boolean,
enabled: Boolean,
colors: SwitchColors,
thumbValue: State<Float>,
interactionSource: InteractionSource
) {
val interactions = remember { mutableStateListOf<Interaction>() }
LaunchedEffect(interactionSource) {
interactionSource.interactions.collect { interaction ->
when (interaction) {
is PressInteraction.Press -> interactions.add(interaction)
is PressInteraction.Release -> interactions.remove(interaction.press)
is PressInteraction.Cancel -> interactions.remove(interaction.press)
is DragInteraction.Start -> interactions.add(interaction)
is DragInteraction.Stop -> interactions.remove(interaction.start)
is DragInteraction.Cancel -> interactions.remove(interaction.start)
}
}
}
val trackColor by colors.trackColor(enabled, checked)
Canvas(Modifier.align(Alignment.Center).fillMaxSize()) {
drawTrack(trackColor, TrackWidth.toPx(), TrackStrokeWidth.toPx())
}
val thumbColor by colors.thumbColor(enabled, checked)
Spacer(
Modifier
.align(Alignment.CenterStart)
.offset { IntOffset(thumbValue.value.roundToInt(), 0) }
.indication(
interactionSource = interactionSource,
indication = rememberRipple(bounded = false, radius = ThumbRippleRadius)
)
.requiredSize(ThumbDiameter)
.background(thumbColor, CircleShape)
)
}
private fun DrawScope.drawTrack(trackColor: Color, trackWidth: Float, strokeWidth: Float) {
val strokeRadius = strokeWidth / 2
drawLine(
trackColor,
Offset(strokeRadius, center.y),
Offset(trackWidth - strokeRadius, center.y),
strokeWidth,
StrokeCap.Round
)
}
internal val TrackWidth = 52.dp
internal val TrackStrokeWidth = 28.dp
internal val ThumbDiameter = 20.dp
private val ThumbRippleRadius = 24.dp
private val DefaultSwitchPadding = 2.dp
private val SwitchWidth = TrackWidth
private val SwitchHeight = ThumbDiameter
private val ThumbInset = 4.dp
private val ThumbPathLength = TrackWidth - ThumbDiameter
private val AnimationSpec = TweenSpec<Float>(durationMillis = 100)
/**
* Contains the default values used by [Switch]
*/
object SwitchDefaults {
/**
* Creates a [SwitchColors] that represents the different colors used in a [Switch] in
* different states.
*/
@Composable
fun colors(): SwitchColors {
val context = LocalContext.current
val isLightTheme = MaterialTheme.colors.isLight
val colorMode = if (isLightTheme) UiColorMode.Light else UiColorMode.Dark
val thumbOn = ColorTokens.SwitchThumbOn.resolve(context, colorMode).toComposeColor()
val thumbOff = ColorTokens.SwitchThumbOff.resolve(context, colorMode).toComposeColor()
val thumbDisabled = ColorTokens.SwitchThumbDisabled.resolve(context, colorMode).toComposeColor()
val trackOn = ColorTokens.SwitchTrackOn.resolve(context, colorMode).toComposeColor()
val trackOff = ColorTokens.SwitchTrackOff.resolve(context, colorMode).toComposeColor()
val trackDisabled = trackOff.copy(alpha = ContentAlpha.disabled)
return DefaultSwitchColors(
checkedThumbColor = thumbOn,
checkedTrackColor = trackOn,
uncheckedThumbColor = thumbOff,
uncheckedTrackColor = trackOff,
disabledCheckedThumbColor = thumbDisabled,
disabledCheckedTrackColor = trackDisabled,
disabledUncheckedThumbColor = thumbDisabled,
disabledUncheckedTrackColor = trackDisabled
)
}
}
/**
* Default [SwitchColors] implementation.
*/
@Immutable
private class DefaultSwitchColors(
private val checkedThumbColor: Color,
private val checkedTrackColor: Color,
private val uncheckedThumbColor: Color,
private val uncheckedTrackColor: Color,
private val disabledCheckedThumbColor: Color,
private val disabledCheckedTrackColor: Color,
private val disabledUncheckedThumbColor: Color,
private val disabledUncheckedTrackColor: Color
) : SwitchColors {
@Composable
override fun thumbColor(enabled: Boolean, checked: Boolean): State<Color> {
return rememberUpdatedState(
if (enabled) {
if (checked) checkedThumbColor else uncheckedThumbColor
} else {
if (checked) disabledCheckedThumbColor else disabledUncheckedThumbColor
}
)
}
@Composable
override fun trackColor(enabled: Boolean, checked: Boolean): State<Color> {
return rememberUpdatedState(
if (enabled) {
if (checked) checkedTrackColor else uncheckedTrackColor
} else {
if (checked) disabledCheckedTrackColor else disabledUncheckedTrackColor
}
)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as DefaultSwitchColors
if (checkedThumbColor != other.checkedThumbColor) return false
if (checkedTrackColor != other.checkedTrackColor) return false
if (uncheckedThumbColor != other.uncheckedThumbColor) return false
if (uncheckedTrackColor != other.uncheckedTrackColor) return false
if (disabledCheckedThumbColor != other.disabledCheckedThumbColor) return false
if (disabledCheckedTrackColor != other.disabledCheckedTrackColor) return false
if (disabledUncheckedThumbColor != other.disabledUncheckedThumbColor) return false
if (disabledUncheckedTrackColor != other.disabledUncheckedTrackColor) return false
return true
}
override fun hashCode(): Int {
var result = checkedThumbColor.hashCode()
result = 31 * result + checkedTrackColor.hashCode()
result = 31 * result + uncheckedThumbColor.hashCode()
result = 31 * result + uncheckedTrackColor.hashCode()
result = 31 * result + disabledCheckedThumbColor.hashCode()
result = 31 * result + disabledCheckedTrackColor.hashCode()
result = 31 * result + disabledUncheckedThumbColor.hashCode()
result = 31 * result + disabledUncheckedTrackColor.hashCode()
return result
}
}
/**
* Create and [remember] a [SwipeableState] which is kept in sync with another state, i.e.:
* 1. Whenever the [value] changes, the [SwipeableState] will be animated to that new value.
* 2. Whenever the value of the [SwipeableState] changes (e.g. after a swipe), the owner of the
* [value] will be notified to update their state to the new value of the [SwipeableState] by
* invoking [onValueChange]. If the owner does not update their state to the provided value for
* some reason, then the [SwipeableState] will perform a rollback to the previous, correct value.
*/
@Composable
@ExperimentalMaterialApi
internal fun <T : Any> rememberSwipeableStateFor(
value: T,
onValueChange: (T) -> Unit,
animationSpec: AnimationSpec<Float> = SwipeableDefaults.AnimationSpec
): SwipeableState<T> {
val swipeableState = remember {
SwipeableState(
initialValue = value,
animationSpec = animationSpec,
confirmStateChange = { true }
)
}
val forceAnimationCheck = remember { mutableStateOf(false) }
LaunchedEffect(value, forceAnimationCheck.value) {
if (value != swipeableState.currentValue) {
swipeableState.animateTo(value)
}
}
DisposableEffect(swipeableState.currentValue) {
if (value != swipeableState.currentValue) {
onValueChange(swipeableState.currentValue)
forceAnimationCheck.value = !forceAnimationCheck.value
}
onDispose { }
}
return swipeableState
}
@@ -18,8 +18,6 @@ package app.lawnchair.ui.preferences.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.height
import androidx.compose.material.Switch
import androidx.compose.material.SwitchDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -59,13 +57,12 @@ fun SwitchPreference(
title = { Text(text = label) },
description = { description?.let { Text(text = it) } },
endWidget = {
Switch(
MYSwitch(
modifier = Modifier
.height(24.dp),
checked = checked,
onCheckedChange = onCheckedChange,
enabled = enabled,
colors = SwitchDefaults.colors(checkedThumbColor = MaterialTheme.colorScheme.primary),
)
},
modifier = Modifier
@@ -33,4 +33,4 @@ abstract class ColorScheme {
get() = listOf(accent1, accent2, accent3)
}
private fun Color.toComposeColor() = ComposeColor(toAndroidColor())
fun Color.toComposeColor() = ComposeColor(toAndroidColor())