blob: 82c880bbac50f2d562001b05c1f9c4aa44515598 [file] [log] [blame]
/*
* Copyright 2023 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.wear.compose.materialcore
import androidx.annotation.RestrictTo
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.Indication
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.DrawScope.Companion.DefaultBlendMode
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin
/**
* [Checkbox] provides an animated checkbox for use in material APIs.
*
* @param checked Boolean flag indicating whether this checkbox is currently checked.
* @param modifier Modifier to be applied to the checkbox.
* This can be used to provide a content description for accessibility.
* @param boxColor Composable lambda from which the box color will be obtained.
* @param checkmarkColor Composable lambda from which the check mark color will be obtained.
* @param enabled Boolean flag indicating the enabled state of the [Checkbox] (affects
* the color).
* @param onCheckedChange Callback to be invoked when Checkbox is clicked. If null, then this is
* passive and relies entirely on a higher-level component to control the state.
* @param interactionSource When also providing [onCheckedChange], the [MutableInteractionSource]
* representing the stream of [Interaction]s for the "toggleable" tap area -
* can be used to customise the appearance / behavior of the Checkbox.
* @param progressAnimationSpec Animation spec to animate the progress.
* @param drawBox Draws the checkbox.
* @param width Width of the checkbox.
* @param height Height of the checkbox.
* @param ripple Ripple used for the checkbox.
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
@Composable
fun Checkbox(
checked: Boolean,
modifier: Modifier = Modifier,
boxColor: @Composable (enabled: Boolean, checked: Boolean) -> State<Color>,
checkmarkColor: @Composable (enabled: Boolean, checked: Boolean) -> State<Color>,
enabled: Boolean,
onCheckedChange: ((Boolean) -> Unit)?,
interactionSource: MutableInteractionSource?,
progressAnimationSpec: TweenSpec<Float>,
drawBox: FunctionDrawBox,
width: Dp,
height: Dp,
ripple: Indication
) {
val targetState = if (checked) SelectionStage.Checked else SelectionStage.Unchecked
val transition = updateTransition(targetState, label = "checkboxTransition")
val progress = animateProgress(
transition = transition, label = "Checkbox", animationSpec = progressAnimationSpec
)
val isRtl = isLayoutDirectionRtl()
val startXOffset = if (isRtl) 0.dp else width - height
// For Checkbox, the color and alpha animations have the same duration and easing,
// so we don't need to explicitly animate alpha.
val boxColorState = boxColor(enabled, checked)
val checkmarkColorState = checkmarkColor(enabled, checked)
// Canvas internally uses Spacer.drawBehind.
// Using Spacer.drawWithCache to optimize the stroke allocations.
Spacer(
modifier = modifier
.semantics {
this.role = Role.Checkbox
}
.maybeToggleable(
onCheckedChange,
enabled,
checked,
interactionSource,
ripple,
width,
height
)
.drawWithCache
{
onDrawWithContent {
drawBox(this, boxColorState.value, progress.value, isRtl)
if (targetState == SelectionStage.Checked) {
// Passing startXOffset as we want checkbox to be aligned to the end of the canvas.
drawTick(checkmarkColorState.value, progress.value, startXOffset, enabled)
} else {
// Passing startXOffset as we want checkbox to be aligned to the end of the canvas.
eraseTick(
checkmarkColorState.value,
progress.value,
startXOffset,
enabled
)
}
}
})
}
/**
* [Switch] provides an animated switch for use in material APIs.
*
* @param modifier Modifier to be applied to the switch.
* This can be used to provide a content description for accessibility.
* @param checked Boolean flag indicating whether this switch is currently toggled on.
* @param enabled Boolean flag indicating the enabled state of the [Switch] (affects
* the color).
* @param onCheckedChange Callback to be invoked when Switch is clicked. If null, then this is
* passive and relies entirely on a higher-level component to control the state.
* @param interactionSource When also providing [onCheckedChange], the [MutableInteractionSource]
* representing the stream of [Interaction]s for the "toggleable" tap area -
* can be used to customise the appearance / behavior of the Switch.
* @param trackFillColor Composable lambda from which the fill color of the track will be obtained.
* @param trackStrokeColor Composable lambda from which the stroke color of the track will be obtained.
* @param thumbColor Composable lambda from which the thumb color will be obtained.
* @param thumbIconColor Composable lambda from which the icon color will be obtained.
* @param trackWidth Width of the track.
* @param trackHeight Height of the track.
* @param drawThumb Lambda function to draw the thumb of the switch.
* The lambda is invoked with trackFillColor as the icon color, along with the thumbColor,
* and the progress.
* @param progressAnimationSpec Animation spec to animate the progress.
* @param width Width of the switch.
* @param height Height of the switch.
* @param ripple Ripple used for the switch.
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
@Composable
fun Switch(
modifier: Modifier,
checked: Boolean,
enabled: Boolean,
onCheckedChange: ((Boolean) -> Unit)?,
interactionSource: MutableInteractionSource?,
trackFillColor: @Composable (enabled: Boolean, checked: Boolean) -> State<Color>,
trackStrokeColor: @Composable (enabled: Boolean, checked: Boolean) -> State<Color>,
thumbColor: @Composable (enabled: Boolean, checked: Boolean) -> State<Color>,
thumbIconColor: @Composable (enabled: Boolean, checked: Boolean) -> State<Color>,
trackWidth: Dp,
trackHeight: Dp,
drawThumb: FunctionDrawThumb,
progressAnimationSpec: TweenSpec<Float>,
width: Dp,
height: Dp,
ripple: Indication
) {
val targetState = if (checked) SelectionStage.Checked else SelectionStage.Unchecked
val transition = updateTransition(targetState, label = "switchTransition")
val isRtl = isLayoutDirectionRtl()
val thumbProgress = animateProgress(
transition,
"Switch",
progressAnimationSpec
)
val thumbBackgroundColor = thumbColor(enabled, checked)
val iconColor = thumbIconColor(enabled, checked)
val trackBackgroundFillColor = trackFillColor(enabled, checked)
val trackBackgroundStrokeColor = trackStrokeColor(enabled, checked)
// Canvas internally uses Spacer.drawBehind.
// Using Spacer.drawWithCache to optimize the stroke allocations.
Spacer(
modifier = modifier
.semantics {
this.role = Role.Switch
}
.maybeToggleable(
onCheckedChange,
enabled,
checked,
interactionSource,
ripple,
width,
height
)
.drawWithCache
{
onDrawWithContent {
drawTrack(
fillColor = trackBackgroundFillColor.value,
strokeColor = trackBackgroundStrokeColor.value,
trackWidthPx = trackWidth.toPx(),
trackHeightPx = trackHeight.toPx()
)
// Draw the thumb of the switch.
drawThumb(
this,
thumbBackgroundColor.value,
thumbProgress.value,
iconColor.value,
isRtl
)
}
})
}
/**
* [RadioButton] provides an animated radio button for use in material APIs.
*
* @param modifier Modifier to be applied to the radio button. This can be used to provide a
* content description for accessibility.
* @param selected Boolean flag indicating whether this radio button is currently toggled on.
* @param enabled Boolean flag indicating the enabled state of the [RadioButton] (affects
* the color).
* @param ringColor Composable lambda from which the ring color of the radio button will be obtained.
* @param dotColor Composable lambda from which the dot color of the radio button will be obtained.
* @param onClick Callback to be invoked when RadioButton is clicked. If null, then this is
* passive and relies entirely on a higher-level component to control the state.
* @param interactionSource When also providing [onClick], the [MutableInteractionSource]
* representing the stream of [Interaction]s for the "toggleable" tap area -
* can be used to customise the appearance / behavior of the RadioButton.
* @param dotRadiusProgressDuration Duration of the dot radius progress animation.
* @param dotAlphaProgressDuration Duration of the dot alpha progress animation.
* @param dotAlphaProgressDelay Delay for the dot alpha progress animation.
* @param easing Animation spec to animate the progress.
* @param width Width of the radio button.
* @param height Height of the radio button.
* @param ripple Ripple used for the radio button.
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
@Composable
fun RadioButton(
modifier: Modifier,
selected: Boolean,
enabled: Boolean,
ringColor: @Composable (enabled: Boolean, checked: Boolean) -> State<Color>,
dotColor: @Composable (enabled: Boolean, checked: Boolean) -> State<Color>,
onClick: (() -> Unit)?,
interactionSource: MutableInteractionSource?,
dotRadiusProgressDuration: FunctionDotRadiusProgressDuration,
dotAlphaProgressDuration: Int,
dotAlphaProgressDelay: Int,
easing: CubicBezierEasing,
width: Dp,
height: Dp,
ripple: Indication
) {
val targetState = if (selected) SelectionStage.Checked else SelectionStage.Unchecked
val transition = updateTransition(targetState)
val isRtl = isLayoutDirectionRtl()
val radioRingColor = ringColor(enabled, selected)
val radioDotColor = dotColor(enabled, selected)
val dotRadiusProgress = animateProgress(
transition = transition,
label = "dot-radius",
animationSpec = tween(dotRadiusProgressDuration(selected), 0, easing)
)
// Animation of the dot alpha only happens when toggling On to Off.
val dotAlphaProgress =
if (targetState == SelectionStage.Unchecked)
animateProgress(
transition = transition,
label = "dot-alpha",
animationSpec = tween(
dotAlphaProgressDuration,
dotAlphaProgressDelay,
easing
)
)
else
null
// Canvas internally uses Spacer.drawBehind.
// Using Spacer.drawWithCache to optimize the stroke allocations.
Spacer(
modifier = modifier
.semantics {
this.role = Role.RadioButton
}
.maybeSelectable(
onClick, enabled, selected, interactionSource, ripple, width, height
)
.drawWithCache
{
// Aligning the radio to the end.
val startXOffsetPx = if (isRtl) -(width - height).toPx() / 2 else
(width - height).toPx() / 2
// Outer circle has a constant radius.
onDrawWithContent {
val circleCenter = Offset(center.x + startXOffsetPx, center.y)
drawCircle(
radius = RADIO_CIRCLE_RADIUS.toPx(),
color = radioRingColor.value,
center = circleCenter,
style = Stroke(RADIO_CIRCLE_STROKE.toPx()),
)
// Inner dot radius expands/shrinks.
drawCircle(
radius = dotRadiusProgress.value * RADIO_DOT_RADIUS.toPx(),
color = radioDotColor.value.copy(
alpha = (dotAlphaProgress?.value ?: 1f) * radioDotColor.value.alpha
),
center = circleCenter,
style = Fill,
)
}
})
}
/**
* Returns the color for the selectionControl.
*
* @param enabled Boolean flag checking if the selection control is enabled.
* @param checked Boolean flag checking if the selection control is checked [SelectionStage].
* @param checkedColor Color for selection control when [enabled] = true and [checked] = true.
* @param uncheckedColor Color for selection control when [enabled] = true and [checked] = false.
* @param disabledCheckedColor Color for selection control when [enabled] = false and [checked] = true.
* @param disabledUncheckedColor Color for selection control when [enabled] = false and [checked] = false.
* @param animationSpec AnimationSpec for the color transition animations.
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
@Composable
fun animateSelectionColor(
enabled: Boolean,
checked: Boolean,
checkedColor: Color,
uncheckedColor: Color,
disabledCheckedColor: Color,
disabledUncheckedColor: Color,
animationSpec: AnimationSpec<Color>
): State<Color> = animateColorAsState(
targetValue = if (enabled) {
if (checked) checkedColor else uncheckedColor
} else {
if (checked) disabledCheckedColor else disabledUncheckedColor
},
animationSpec = animationSpec
)
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
enum class SelectionStage {
Unchecked, Checked
}
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
fun interface FunctionDrawBox {
operator fun invoke(drawScope: DrawScope, color: Color, progress: Float, isRtl: Boolean)
}
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
fun interface FunctionDrawThumb {
operator fun invoke(
drawScope: DrawScope,
thumbColor: Color,
progress: Float,
thumbIconColor: Color,
isRtl: Boolean
)
}
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
fun interface FunctionDotRadiusProgressDuration {
operator fun invoke(selected: Boolean): Int
}
@Composable
private fun animateProgress(
transition: Transition<SelectionStage>,
label: String,
animationSpec: TweenSpec<Float>,
) = transition.animateFloat(
transitionSpec = {
animationSpec
}, label = label
) {
when (it) {
SelectionStage.Unchecked -> 0f
SelectionStage.Checked -> 1f
}
}
private fun Modifier.maybeToggleable(
onCheckedChange: ((Boolean) -> Unit)?,
enabled: Boolean,
checked: Boolean,
interactionSource: MutableInteractionSource?,
indication: Indication,
canvasWidth: Dp,
canvasHeight: Dp
): Modifier {
val standardModifier = this
.wrapContentSize(Alignment.CenterEnd)
.requiredSize(canvasWidth, canvasHeight)
return if (onCheckedChange == null || interactionSource == null) {
standardModifier
} else {
standardModifier.then(
Modifier.toggleable(
enabled = enabled,
value = checked,
onValueChange = onCheckedChange,
indication = indication,
interactionSource = interactionSource
)
)
}
}
private fun Modifier.maybeSelectable(
onClick: (() -> Unit)?,
enabled: Boolean,
selected: Boolean,
interactionSource: MutableInteractionSource?,
indication: Indication,
canvasWidth: Dp,
canvasHeight: Dp
): Modifier {
val standardModifier = this
.wrapContentSize(Alignment.Center)
.requiredSize(canvasWidth, canvasHeight)
return if (onClick == null || interactionSource == null) {
standardModifier
} else {
standardModifier.then(
Modifier.selectable(
selected = selected,
interactionSource = interactionSource,
indication = indication,
enabled = enabled,
role = Role.RadioButton,
onClick = onClick,
)
)
}
}
private fun DrawScope.drawTick(
tickColor: Color,
tickProgress: Float,
startXOffset: Dp,
enabled: Boolean,
) {
// Using tickProgress animating from zero to TICK_TOTAL_LENGTH,
// rotate the tick as we draw from 15 degrees to zero.
val tickBaseLength = TICK_BASE_LENGTH.toPx()
val tickStickLength = TICK_STICK_LENGTH.toPx()
val tickTotalLength = tickBaseLength + tickStickLength
val tickProgressPx = tickProgress * tickTotalLength
val startXOffsetPx = startXOffset.toPx()
val center = Offset(12.dp.toPx() + startXOffsetPx, 12.dp.toPx())
val angle = TICK_ROTATION - TICK_ROTATION / tickTotalLength * tickProgressPx
val angleRadians = angle.toRadians()
// Animate the base of the tick.
val baseStart = Offset(6.7f.dp.toPx() + startXOffsetPx, 12.3f.dp.toPx())
val tickBaseProgress = min(tickProgressPx, tickBaseLength)
val path = Path()
path.moveTo(baseStart.rotate(angleRadians, center))
path.lineTo(
(baseStart + Offset(tickBaseProgress, tickBaseProgress)).rotate(angleRadians, center)
)
if (tickProgressPx > tickBaseLength) {
val tickStickProgress = min(tickProgressPx - tickBaseLength, tickStickLength)
val stickStart = Offset(9.3f.dp.toPx() + startXOffsetPx, 16.3f.dp.toPx())
// Move back to the start of the stick (without drawing)
path.moveTo(stickStart.rotate(angleRadians, center))
path.lineTo(
Offset(stickStart.x + tickStickProgress, stickStart.y - tickStickProgress).rotate(
angleRadians,
center
)
)
}
// Use StrokeCap.Butt because Square adds an extension on the end of each line.
drawPath(
path, tickColor, style = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Butt), blendMode =
if (enabled) DefaultBlendMode else BlendMode.Hardlight
)
}
private fun DrawScope.drawTrack(
fillColor: Color,
strokeColor: Color,
trackWidthPx: Float,
trackHeightPx: Float,
) {
val path = Path()
val strokeRadius = trackHeightPx / 2f
path.moveTo(Offset(strokeRadius, center.y))
path.lineTo(Offset(trackWidthPx - strokeRadius, center.y))
// Draws the border of the track
drawPath(
path = path,
color = strokeColor,
style = Stroke(width = trackHeightPx, cap = StrokeCap.Round),
)
// If strokeColor and fillColor are different, drawing another path for the fill of the track.
if (strokeColor != fillColor) {
drawPath(
path = path,
color = fillColor,
style = Stroke(
width = trackHeightPx - 2 * SWITCH_TRACK_BORDER.toPx(),
cap = StrokeCap.Round
)
)
}
}
private fun DrawScope.eraseTick(
tickColor: Color,
tickProgress: Float,
startXOffset: Dp,
enabled: Boolean
) {
val tickBaseLength = TICK_BASE_LENGTH.toPx()
val tickStickLength = TICK_STICK_LENGTH.toPx()
val tickTotalLength = tickBaseLength + tickStickLength
val tickProgressPx = tickProgress * tickTotalLength
val startXOffsetPx = startXOffset.toPx()
// Animate the stick of the tick, drawing down the stick from the top.
val stickStartX = 17.3f.dp.toPx() + startXOffsetPx
val stickStartY = 8.3f.dp.toPx()
val tickStickProgress = min(tickProgressPx, tickStickLength)
val path = Path()
path.moveTo(stickStartX, stickStartY)
path.lineTo(stickStartX - tickStickProgress, stickStartY + tickStickProgress)
if (tickStickProgress > tickStickLength) {
// Animate the base of the tick, drawing up the base from bottom of the stick.
val tickBaseProgress = min(tickProgressPx - tickStickLength, tickBaseLength)
val baseStartX = 10.7f.dp.toPx() + startXOffsetPx
val baseStartY = 16.3f.dp.toPx()
path.moveTo(baseStartX, baseStartY)
path.lineTo(baseStartX - tickBaseProgress, baseStartY - tickBaseProgress)
}
drawPath(
path, tickColor, style = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Butt), blendMode =
if (enabled) DefaultBlendMode else BlendMode.Hardlight
)
}
private fun Path.moveTo(offset: Offset) {
moveTo(offset.x, offset.y)
}
private fun Path.lineTo(offset: Offset) {
lineTo(offset.x, offset.y)
}
private fun Offset.rotate(angleRadians: Float): Offset {
val angledDirection = directionVector(angleRadians)
return angledDirection * x + angledDirection.rotate90() * y
}
private fun Offset.rotate(angleRadians: Float, center: Offset): Offset =
(this - center).rotate(angleRadians) + center
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
fun directionVector(angleRadians: Float) = Offset(cos(angleRadians), sin(angleRadians))
private fun Offset.rotate90() = Offset(-y, x)
// This is duplicated from wear.compose.foundation/geometry.kt
// Any changes should be replicated there.
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
fun Float.toRadians() = this * PI.toFloat() / 180f
private val TICK_BASE_LENGTH = 4.dp
private val TICK_STICK_LENGTH = 8.dp
private const val TICK_ROTATION = 15f
private val SWITCH_TRACK_BORDER = 1.dp
private val RADIO_CIRCLE_RADIUS = 9.dp
private val RADIO_CIRCLE_STROKE = 2.dp
private val RADIO_DOT_RADIUS = 5.dp