From da56f4203148ab2913ae012d8c8bec53e7e7dd73 Mon Sep 17 00:00:00 2001 From: Suphon Thanakornpakapong Date: Tue, 10 May 2022 10:17:47 +0700 Subject: [PATCH] Update switch style to match settings --- .../app/lawnchair/theme/color/ColorTokens.kt | 9 + lawnchair/src/app/lawnchair/ui/TouchTarget.kt | 95 +++++ .../ui/preferences/components/MYSwitch.kt | 372 ++++++++++++++++++ .../components/SwitchPreference.kt | 5 +- .../dev/kdrag0n/monet/theme/ColorScheme.kt | 2 +- 5 files changed, 478 insertions(+), 5 deletions(-) create mode 100644 lawnchair/src/app/lawnchair/ui/TouchTarget.kt create mode 100644 lawnchair/src/app/lawnchair/ui/preferences/components/MYSwitch.kt diff --git a/lawnchair/src/app/lawnchair/theme/color/ColorTokens.kt b/lawnchair/src/app/lawnchair/theme/color/ColorTokens.kt index e8ba11750e..443137053d 100644 --- a/lawnchair/src/app/lawnchair/theme/color/ColorTokens.kt +++ b/lawnchair/src/app/lawnchair/theme/color/ColorTokens.kt @@ -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 diff --git a/lawnchair/src/app/lawnchair/ui/TouchTarget.kt b/lawnchair/src/app/lawnchair/ui/TouchTarget.kt new file mode 100644 index 0000000000..571b602a61 --- /dev/null +++ b/lawnchair/src/app/lawnchair/ui/TouchTarget.kt @@ -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 = + 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() + } +} diff --git a/lawnchair/src/app/lawnchair/ui/preferences/components/MYSwitch.kt b/lawnchair/src/app/lawnchair/ui/preferences/components/MYSwitch.kt new file mode 100644 index 0000000000..efc37775b5 --- /dev/null +++ b/lawnchair/src/app/lawnchair/ui/preferences/components/MYSwitch.kt @@ -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 + +/** + * Material Design switch. + * + * 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 + + /** + * 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 +} + +@Composable +private fun BoxScope.MYSwitchImpl( + checked: Boolean, + enabled: Boolean, + colors: SwitchColors, + thumbValue: State, + interactionSource: InteractionSource +) { + val interactions = remember { mutableStateListOf() } + + 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(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 { + return rememberUpdatedState( + if (enabled) { + if (checked) checkedThumbColor else uncheckedThumbColor + } else { + if (checked) disabledCheckedThumbColor else disabledUncheckedThumbColor + } + ) + } + + @Composable + override fun trackColor(enabled: Boolean, checked: Boolean): State { + 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 rememberSwipeableStateFor( + value: T, + onValueChange: (T) -> Unit, + animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec +): SwipeableState { + 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 +} diff --git a/lawnchair/src/app/lawnchair/ui/preferences/components/SwitchPreference.kt b/lawnchair/src/app/lawnchair/ui/preferences/components/SwitchPreference.kt index ad53f6beb7..3849670e6e 100644 --- a/lawnchair/src/app/lawnchair/ui/preferences/components/SwitchPreference.kt +++ b/lawnchair/src/app/lawnchair/ui/preferences/components/SwitchPreference.kt @@ -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 diff --git a/lawnchair/src/dev/kdrag0n/monet/theme/ColorScheme.kt b/lawnchair/src/dev/kdrag0n/monet/theme/ColorScheme.kt index c53e764380..1fa6b6ee99 100644 --- a/lawnchair/src/dev/kdrag0n/monet/theme/ColorScheme.kt +++ b/lawnchair/src/dev/kdrag0n/monet/theme/ColorScheme.kt @@ -33,4 +33,4 @@ abstract class ColorScheme { get() = listOf(accent1, accent2, accent3) } -private fun Color.toComposeColor() = ComposeColor(toAndroidColor()) +fun Color.toComposeColor() = ComposeColor(toAndroidColor())