| /* |
| * 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 androidx.compose.material3 |
| |
| import androidx.compose.animation.animateColorAsState |
| import androidx.compose.animation.core.animateFloat |
| import androidx.compose.animation.core.snap |
| import androidx.compose.animation.core.spring |
| import androidx.compose.animation.core.tween |
| import androidx.compose.animation.core.updateTransition |
| import androidx.compose.foundation.Canvas |
| import androidx.compose.foundation.interaction.Interaction |
| import androidx.compose.foundation.interaction.MutableInteractionSource |
| import androidx.compose.foundation.layout.padding |
| import androidx.compose.foundation.layout.requiredSize |
| import androidx.compose.foundation.layout.wrapContentSize |
| import androidx.compose.foundation.selection.triStateToggleable |
| import androidx.compose.material.ripple.rememberRipple |
| import androidx.compose.material3.tokens.CheckboxTokens |
| import androidx.compose.runtime.Composable |
| import androidx.compose.runtime.Immutable |
| import androidx.compose.runtime.Stable |
| import androidx.compose.runtime.State |
| import androidx.compose.runtime.remember |
| import androidx.compose.runtime.rememberUpdatedState |
| import androidx.compose.ui.Alignment |
| import androidx.compose.ui.Modifier |
| import androidx.compose.ui.geometry.CornerRadius |
| import androidx.compose.ui.geometry.Offset |
| import androidx.compose.ui.geometry.Size |
| import androidx.compose.ui.graphics.Color |
| import androidx.compose.ui.graphics.Path |
| import androidx.compose.ui.graphics.PathMeasure |
| import androidx.compose.ui.graphics.StrokeCap |
| import androidx.compose.ui.graphics.drawscope.DrawScope |
| import androidx.compose.ui.graphics.drawscope.Fill |
| import androidx.compose.ui.graphics.drawscope.Stroke |
| import androidx.compose.ui.semantics.Role |
| import androidx.compose.ui.state.ToggleableState |
| import androidx.compose.ui.unit.dp |
| import androidx.compose.ui.util.lerp |
| import kotlin.math.floor |
| import kotlin.math.max |
| |
| /** |
| * Material Design checkbox. |
| * |
| * Checkboxes allow users to select one or more items from a set. Checkboxes can turn an option on |
| * or off. |
| * |
| * ![Checkbox image](https://developer.android.com/images/reference/androidx/compose/material3/checkbox.png) |
| * |
| * @sample androidx.compose.material3.samples.CheckboxSample |
| * |
| * @see [TriStateCheckbox] if you require support for an indeterminate state. |
| * |
| * @param checked whether this checkbox is checked or unchecked |
| * @param onCheckedChange called when this checkbox is clicked. If `null`, then this checkbox will |
| * not be interactable, unless something else handles its input events and updates its state. |
| * @param modifier the [Modifier] to be applied to this checkbox |
| * @param enabled controls the enabled state of this checkbox. When `false`, this component will not |
| * respond to user input, and it will appear visually disabled and disabled to accessibility |
| * services. |
| * @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s |
| * for this checkbox. You can create and pass in your own `remember`ed instance to observe |
| * [Interaction]s and customize the appearance / behavior of this checkbox in different states. |
| * @param colors [CheckboxColors] that will be used to resolve the colors used for this checkbox in |
| * different states. See [CheckboxDefaults.colors]. |
| */ |
| @ExperimentalMaterial3Api |
| @Composable |
| fun Checkbox( |
| checked: Boolean, |
| onCheckedChange: ((Boolean) -> Unit)?, |
| modifier: Modifier = Modifier, |
| enabled: Boolean = true, |
| interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, |
| colors: CheckboxColors = CheckboxDefaults.colors() |
| ) { |
| TriStateCheckbox( |
| state = ToggleableState(checked), |
| onClick = if (onCheckedChange != null) { |
| { onCheckedChange(!checked) } |
| } else null, |
| interactionSource = interactionSource, |
| enabled = enabled, |
| colors = colors, |
| modifier = modifier |
| ) |
| } |
| |
| /** |
| * Material Design checkbox parent. |
| * |
| * Checkboxes can have a parent-child relationship with other checkboxes. When the parent checkbox |
| * is checked, all child checkboxes are checked. If a parent checkbox is unchecked, all child |
| * checkboxes are unchecked. If some, but not all, child checkboxes are checked, the parent checkbox |
| * becomes an indeterminate checkbox. |
| * |
| * ![Checkbox image](https://developer.android.com/images/reference/androidx/compose/material3/indeterminate-checkbox.png) |
| * |
| * @sample androidx.compose.material3.samples.TriStateCheckboxSample |
| * |
| * @see [Checkbox] if you want a simple component that represents Boolean state |
| * |
| * @param state whether this checkbox is checked, unchecked, or in an indeterminate state |
| * @param onClick called when this checkbox is clicked. If `null`, then this checkbox will not be |
| * interactable, unless something else handles its input events and updates its [state]. |
| * @param modifier the [Modifier] to be applied to this checkbox |
| * @param enabled controls the enabled state of this checkbox. When `false`, this component will not |
| * respond to user input, and it will appear visually disabled and disabled to accessibility |
| * services. |
| * @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s |
| * for this checkbox. You can create and pass in your own `remember`ed instance to observe |
| * [Interaction]s and customize the appearance / behavior of this checkbox in different states. |
| * @param colors [CheckboxColors] that will be used to resolve the colors used for this checkbox in |
| * different states. See [CheckboxDefaults.colors]. |
| */ |
| @Composable |
| fun TriStateCheckbox( |
| state: ToggleableState, |
| onClick: (() -> Unit)?, |
| modifier: Modifier = Modifier, |
| enabled: Boolean = true, |
| interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, |
| colors: CheckboxColors = CheckboxDefaults.colors() |
| ) { |
| val toggleableModifier = |
| if (onClick != null) { |
| Modifier.triStateToggleable( |
| state = state, |
| onClick = onClick, |
| enabled = enabled, |
| role = Role.Checkbox, |
| interactionSource = interactionSource, |
| indication = rememberRipple( |
| bounded = false, |
| radius = CheckboxTokens.StateLayerSize / 2 |
| ) |
| ) |
| } else { |
| Modifier |
| } |
| CheckboxImpl( |
| enabled = enabled, |
| value = state, |
| modifier = modifier |
| .then( |
| if (onClick != null) { |
| Modifier.minimumTouchTargetSize() |
| } else { |
| Modifier |
| } |
| ) |
| .then(toggleableModifier) |
| .padding(CheckboxDefaultPadding), |
| colors = colors |
| ) |
| } |
| |
| /** |
| * Represents the colors used by the three different sections (checkmark, box, and border) of a |
| * [Checkbox] or [TriStateCheckbox] in different states. |
| * |
| * See [CheckboxDefaults.colors] for the default implementation that follows Material |
| * specifications. |
| */ |
| @Stable |
| interface CheckboxColors { |
| |
| /** |
| * Represents the color used for the checkmark inside the checkbox, depending on [state]. |
| * |
| * @param state the [ToggleableState] of the checkbox |
| */ |
| @Composable |
| fun checkmarkColor(state: ToggleableState): State<Color> |
| |
| /** |
| * Represents the color used for the box (background) of the checkbox, depending on [enabled] |
| * and [state]. |
| * |
| * @param enabled whether the checkbox is enabled or not |
| * @param state the [ToggleableState] of the checkbox |
| */ |
| @Composable |
| fun boxColor(enabled: Boolean, state: ToggleableState): State<Color> |
| |
| /** |
| * Represents the color used for the border of the checkbox, depending on [enabled] and [state]. |
| * |
| * @param enabled whether the checkbox is enabled or not |
| * @param state the [ToggleableState] of the checkbox |
| */ |
| @Composable |
| fun borderColor(enabled: Boolean, state: ToggleableState): State<Color> |
| } |
| |
| /** |
| * Defaults used in [Checkbox] and [TriStateCheckbox]. |
| */ |
| object CheckboxDefaults { |
| /** |
| * Creates a [CheckboxColors] that will animate between the provided colors according to the |
| * Material specification. |
| * |
| * @param checkedColor the color that will be used for the border and box when checked |
| * @param uncheckedColor color that will be used for the border when unchecked |
| * @param checkmarkColor color that will be used for the checkmark when checked |
| * @param disabledCheckedColor color that will be used for the box and border when disabled and |
| * checked |
| * @param disabledUncheckedColor color that will be used for the box and border when disabled |
| * and not checked |
| * @param disabledIndeterminateColor color that will be used for the box and |
| * border in a [TriStateCheckbox] when disabled AND in an [ToggleableState.Indeterminate] state. |
| */ |
| @Composable |
| fun colors( |
| checkedColor: Color = |
| MaterialTheme.colorScheme.fromToken(CheckboxTokens.SelectedContainerColor), |
| uncheckedColor: Color = |
| MaterialTheme.colorScheme.fromToken(CheckboxTokens.UnselectedOutlineColor), |
| checkmarkColor: Color = |
| MaterialTheme.colorScheme.fromToken(CheckboxTokens.SelectedIconColor), |
| disabledCheckedColor: Color = |
| MaterialTheme.colorScheme |
| .fromToken(CheckboxTokens.SelectedDisabledContainerColor) |
| .copy(alpha = CheckboxTokens.SelectedDisabledContainerOpacity), |
| disabledUncheckedColor: Color = |
| MaterialTheme.colorScheme |
| .fromToken(CheckboxTokens.UnselectedDisabledOutlineColor) |
| .copy(alpha = CheckboxTokens.UnselectedDisabledContainerOpacity), |
| disabledIndeterminateColor: Color = disabledCheckedColor |
| ): CheckboxColors { |
| return remember( |
| checkedColor, |
| uncheckedColor, |
| checkmarkColor, |
| disabledCheckedColor, |
| disabledUncheckedColor, |
| disabledIndeterminateColor, |
| ) { |
| DefaultCheckboxColors( |
| checkedBorderColor = checkedColor, |
| checkedBoxColor = checkedColor, |
| checkedCheckmarkColor = checkmarkColor, |
| uncheckedCheckmarkColor = checkmarkColor.copy(alpha = 0f), |
| uncheckedBoxColor = checkedColor.copy(alpha = 0f), |
| disabledCheckedBoxColor = disabledCheckedColor, |
| disabledUncheckedBoxColor = disabledUncheckedColor.copy(alpha = 0f), |
| disabledIndeterminateBoxColor = disabledIndeterminateColor, |
| uncheckedBorderColor = uncheckedColor, |
| disabledBorderColor = disabledCheckedColor, |
| disabledIndeterminateBorderColor = disabledIndeterminateColor, |
| ) |
| } |
| } |
| } |
| |
| @Composable |
| private fun CheckboxImpl( |
| enabled: Boolean, |
| value: ToggleableState, |
| modifier: Modifier, |
| colors: CheckboxColors |
| ) { |
| val transition = updateTransition(value) |
| val checkDrawFraction = transition.animateFloat( |
| transitionSpec = { |
| when { |
| initialState == ToggleableState.Off -> tween(CheckAnimationDuration) |
| targetState == ToggleableState.Off -> snap(BoxOutDuration) |
| else -> spring() |
| } |
| } |
| ) { |
| when (it) { |
| ToggleableState.On -> 1f |
| ToggleableState.Off -> 0f |
| ToggleableState.Indeterminate -> 1f |
| } |
| } |
| |
| val checkCenterGravitationShiftFraction = transition.animateFloat( |
| transitionSpec = { |
| when { |
| initialState == ToggleableState.Off -> snap() |
| targetState == ToggleableState.Off -> snap(BoxOutDuration) |
| else -> tween(durationMillis = CheckAnimationDuration) |
| } |
| } |
| ) { |
| when (it) { |
| ToggleableState.On -> 0f |
| ToggleableState.Off -> 0f |
| ToggleableState.Indeterminate -> 1f |
| } |
| } |
| val checkCache = remember { CheckDrawingCache() } |
| val checkColor = colors.checkmarkColor(value) |
| val boxColor = colors.boxColor(enabled, value) |
| val borderColor = colors.borderColor(enabled, value) |
| Canvas(modifier.wrapContentSize(Alignment.Center).requiredSize(CheckboxSize)) { |
| val strokeWidthPx = floor(StrokeWidth.toPx()) |
| drawBox( |
| boxColor = boxColor.value, |
| borderColor = borderColor.value, |
| radius = RadiusSize.toPx(), |
| strokeWidth = strokeWidthPx |
| ) |
| drawCheck( |
| checkColor = checkColor.value, |
| checkFraction = checkDrawFraction.value, |
| crossCenterGravitation = checkCenterGravitationShiftFraction.value, |
| strokeWidthPx = strokeWidthPx, |
| drawingCache = checkCache |
| ) |
| } |
| } |
| |
| private fun DrawScope.drawBox( |
| boxColor: Color, |
| borderColor: Color, |
| radius: Float, |
| strokeWidth: Float |
| ) { |
| val halfStrokeWidth = strokeWidth / 2.0f |
| val stroke = Stroke(strokeWidth) |
| val checkboxSize = size.width |
| if (boxColor == borderColor) { |
| drawRoundRect( |
| boxColor, |
| size = Size(checkboxSize, checkboxSize), |
| cornerRadius = CornerRadius(radius), |
| style = Fill |
| ) |
| } else { |
| drawRoundRect( |
| boxColor, |
| topLeft = Offset(strokeWidth, strokeWidth), |
| size = Size(checkboxSize - strokeWidth * 2, checkboxSize - strokeWidth * 2), |
| cornerRadius = CornerRadius(max(0f, radius - strokeWidth)), |
| style = Fill |
| ) |
| drawRoundRect( |
| borderColor, |
| topLeft = Offset(halfStrokeWidth, halfStrokeWidth), |
| size = Size(checkboxSize - strokeWidth, checkboxSize - strokeWidth), |
| cornerRadius = CornerRadius(radius - halfStrokeWidth), |
| style = stroke |
| ) |
| } |
| } |
| |
| private fun DrawScope.drawCheck( |
| checkColor: Color, |
| checkFraction: Float, |
| crossCenterGravitation: Float, |
| strokeWidthPx: Float, |
| drawingCache: CheckDrawingCache |
| ) { |
| val stroke = Stroke(width = strokeWidthPx, cap = StrokeCap.Square) |
| val width = size.width |
| val checkCrossX = 0.4f |
| val checkCrossY = 0.7f |
| val leftX = 0.2f |
| val leftY = 0.5f |
| val rightX = 0.8f |
| val rightY = 0.3f |
| |
| val gravitatedCrossX = lerp(checkCrossX, 0.5f, crossCenterGravitation) |
| val gravitatedCrossY = lerp(checkCrossY, 0.5f, crossCenterGravitation) |
| // gravitate only Y for end to achieve center line |
| val gravitatedLeftY = lerp(leftY, 0.5f, crossCenterGravitation) |
| val gravitatedRightY = lerp(rightY, 0.5f, crossCenterGravitation) |
| |
| with(drawingCache) { |
| checkPath.reset() |
| checkPath.moveTo(width * leftX, width * gravitatedLeftY) |
| checkPath.lineTo(width * gravitatedCrossX, width * gravitatedCrossY) |
| checkPath.lineTo(width * rightX, width * gravitatedRightY) |
| // TODO: replace with proper declarative non-android alternative when ready (b/158188351) |
| pathMeasure.setPath(checkPath, false) |
| pathToDraw.reset() |
| pathMeasure.getSegment( |
| 0f, pathMeasure.length * checkFraction, pathToDraw, true |
| ) |
| } |
| drawPath(drawingCache.pathToDraw, checkColor, style = stroke) |
| } |
| |
| @Immutable |
| private class CheckDrawingCache( |
| val checkPath: Path = Path(), |
| val pathMeasure: PathMeasure = PathMeasure(), |
| val pathToDraw: Path = Path() |
| ) |
| |
| /** |
| * Default [CheckboxColors] implementation. |
| */ |
| @Immutable |
| private class DefaultCheckboxColors( |
| private val checkedCheckmarkColor: Color, |
| private val uncheckedCheckmarkColor: Color, |
| private val checkedBoxColor: Color, |
| private val uncheckedBoxColor: Color, |
| private val disabledCheckedBoxColor: Color, |
| private val disabledUncheckedBoxColor: Color, |
| private val disabledIndeterminateBoxColor: Color, |
| private val checkedBorderColor: Color, |
| private val uncheckedBorderColor: Color, |
| private val disabledBorderColor: Color, |
| private val disabledIndeterminateBorderColor: Color |
| ) : CheckboxColors { |
| @Composable |
| override fun checkmarkColor(state: ToggleableState): State<Color> { |
| val target = if (state == ToggleableState.Off) { |
| uncheckedCheckmarkColor |
| } else { |
| checkedCheckmarkColor |
| } |
| |
| val duration = if (state == ToggleableState.Off) BoxOutDuration else BoxInDuration |
| return animateColorAsState(target, tween(durationMillis = duration)) |
| } |
| |
| @Composable |
| override fun boxColor(enabled: Boolean, state: ToggleableState): State<Color> { |
| val target = if (enabled) { |
| when (state) { |
| ToggleableState.On, ToggleableState.Indeterminate -> checkedBoxColor |
| ToggleableState.Off -> uncheckedBoxColor |
| } |
| } else { |
| when (state) { |
| ToggleableState.On -> disabledCheckedBoxColor |
| ToggleableState.Indeterminate -> disabledIndeterminateBoxColor |
| ToggleableState.Off -> disabledUncheckedBoxColor |
| } |
| } |
| |
| // If not enabled 'snap' to the disabled state, as there should be no animations between |
| // enabled / disabled. |
| return if (enabled) { |
| val duration = if (state == ToggleableState.Off) BoxOutDuration else BoxInDuration |
| animateColorAsState(target, tween(durationMillis = duration)) |
| } else { |
| rememberUpdatedState(target) |
| } |
| } |
| |
| @Composable |
| override fun borderColor(enabled: Boolean, state: ToggleableState): State<Color> { |
| val target = if (enabled) { |
| when (state) { |
| ToggleableState.On, ToggleableState.Indeterminate -> checkedBorderColor |
| ToggleableState.Off -> uncheckedBorderColor |
| } |
| } else { |
| when (state) { |
| ToggleableState.Indeterminate -> disabledIndeterminateBorderColor |
| ToggleableState.On, ToggleableState.Off -> disabledBorderColor |
| } |
| } |
| |
| // If not enabled 'snap' to the disabled state, as there should be no animations between |
| // enabled / disabled. |
| return if (enabled) { |
| val duration = if (state == ToggleableState.Off) BoxOutDuration else BoxInDuration |
| animateColorAsState(target, tween(durationMillis = duration)) |
| } else { |
| rememberUpdatedState(target) |
| } |
| } |
| |
| override fun equals(other: Any?): Boolean { |
| if (this === other) return true |
| if (other == null || this::class != other::class) return false |
| |
| other as DefaultCheckboxColors |
| |
| if (checkedCheckmarkColor != other.checkedCheckmarkColor) return false |
| if (uncheckedCheckmarkColor != other.uncheckedCheckmarkColor) return false |
| if (checkedBoxColor != other.checkedBoxColor) return false |
| if (uncheckedBoxColor != other.uncheckedBoxColor) return false |
| if (disabledCheckedBoxColor != other.disabledCheckedBoxColor) return false |
| if (disabledUncheckedBoxColor != other.disabledUncheckedBoxColor) return false |
| if (disabledIndeterminateBoxColor != other.disabledIndeterminateBoxColor) return false |
| if (checkedBorderColor != other.checkedBorderColor) return false |
| if (uncheckedBorderColor != other.uncheckedBorderColor) return false |
| if (disabledBorderColor != other.disabledBorderColor) return false |
| if (disabledIndeterminateBorderColor != other.disabledIndeterminateBorderColor) return false |
| |
| return true |
| } |
| |
| override fun hashCode(): Int { |
| var result = checkedCheckmarkColor.hashCode() |
| result = 31 * result + uncheckedCheckmarkColor.hashCode() |
| result = 31 * result + checkedBoxColor.hashCode() |
| result = 31 * result + uncheckedBoxColor.hashCode() |
| result = 31 * result + disabledCheckedBoxColor.hashCode() |
| result = 31 * result + disabledUncheckedBoxColor.hashCode() |
| result = 31 * result + disabledIndeterminateBoxColor.hashCode() |
| result = 31 * result + checkedBorderColor.hashCode() |
| result = 31 * result + uncheckedBorderColor.hashCode() |
| result = 31 * result + disabledBorderColor.hashCode() |
| result = 31 * result + disabledIndeterminateBorderColor.hashCode() |
| return result |
| } |
| } |
| |
| private const val BoxInDuration = 50 |
| private const val BoxOutDuration = 100 |
| private const val CheckAnimationDuration = 100 |
| |
| // TODO(b/188529841): Update the padding and size when the Checkbox spec is finalized. |
| private val CheckboxDefaultPadding = 2.dp |
| private val CheckboxSize = 20.dp |
| private val StrokeWidth = 2.dp |
| private val RadiusSize = 2.dp |