/*
* 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 androidx.compose.material
import androidx.compose.animation.AnimatedValueModel
import androidx.compose.animation.VectorConverter
import androidx.compose.animation.animate
import androidx.compose.animation.asDisposableClock
import androidx.compose.animation.core.AnimationClockObservable
import androidx.compose.animation.core.AnimationVector4D
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Interaction
import androidx.compose.foundation.InteractionState
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.selection.selectable
import androidx.compose.material.ripple.rememberRippleIndication
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.platform.AmbientAnimationClock
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Component to represent two states, selected and not selected.
*
* @sample androidx.compose.material.samples.RadioButtonSample
*
* [RadioButton]s can be combined together with [Text] in the desired layout (e.g. [Column] or
* [Row]) to achieve radio group-like behaviour, where the entire layout is selectable:
*
* @sample androidx.compose.material.samples.RadioGroupSample
*
* @param selected boolean state for this button: either it is selected or not
* @param onClick callback to be invoked when the RadioButton is being clicked
* @param modifier Modifier to be applied to the radio button
* @param enabled Controls the enabled state of the [RadioButton]. When `false`, this button will
* not be selectable and appears in the disabled ui state
* @param interactionState the [InteractionState] representing the different [Interaction]s
* present on this RadioButton. You can create and pass in your own remembered
* [InteractionState] if you want to read the [InteractionState] and customize the appearance /
* behavior of this RadioButton in different [Interaction]s.
* @param colors [RadioButtonColors] that will be used to resolve the color used for this
* RadioButton in different states. See [RadioButtonConstants.defaultColors].
*/
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun RadioButton(
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
interactionState: InteractionState = remember { InteractionState() },
colors: RadioButtonColors = RadioButtonConstants.defaultColors()
) {
val dotRadius = animate(
target = if (selected) RadioButtonDotSize / 2 else 0.dp,
animSpec = tween(durationMillis = RadioAnimationDuration)
)
Canvas(
modifier
.selectable(
selected = selected,
onClick = onClick,
enabled = enabled,
interactionState = interactionState,
indication = rememberRippleIndication(
bounded = false,
radius = RadioButtonRippleRadius
)
)
.wrapContentSize(Alignment.Center)
.padding(RadioButtonPadding)
.size(RadioButtonSize)
) {
val radioColor = colors.radioColor(enabled, selected)
drawRadio(radioColor, dotRadius)
}
}
/**
* Represents the color used by a [RadioButton] in different states.
*
* See [RadioButtonConstants.defaultColors] for the default implementation that follows Material
* specifications.
*/
@ExperimentalMaterialApi
@Stable
interface RadioButtonColors {
/**
* Represents the main color used to draw the outer and inner circles, depending on whether
* the [RadioButton] is [enabled] / [selected].
*
* @param enabled whether the [RadioButton] is enabled
* @param selected whether the [RadioButton] is selected
*/
fun radioColor(enabled: Boolean, selected: Boolean): Color
}
/**
* Constants used in [RadioButton].
*/
object RadioButtonConstants {
/**
* Creates a [RadioButtonColors] that will animate between the provided colors according to
* the Material specification.
*
* @param selectedColor the color to use for the RadioButton when selected and enabled.
* @param unselectedColor the color to use for the RadioButton when unselected and enabled.
* @param disabledColor the color to use for the RadioButton when disabled.
* @return the resulting [Color] used for the RadioButton
*/
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun defaultColors(
selectedColor: Color = MaterialTheme.colors.secondary,
unselectedColor: Color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
disabledColor: Color = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled)
): RadioButtonColors {
val clock = AmbientAnimationClock.current.asDisposableClock()
return remember(
selectedColor,
unselectedColor,
disabledColor,
clock
) {
DefaultRadioButtonColors(selectedColor, unselectedColor, disabledColor, clock)
}
}
}
private fun DrawScope.drawRadio(color: Color, dotRadius: Dp) {
val strokeWidth = RadioStrokeWidth.toPx()
drawCircle(color, RadioRadius.toPx() - strokeWidth / 2, style = Stroke(strokeWidth))
if (dotRadius > 0.dp) {
drawCircle(color, dotRadius.toPx() - strokeWidth / 2, style = Fill)
}
}
/**
* Default [RadioButtonColors] implementation.
*/
@OptIn(ExperimentalMaterialApi::class)
@Stable
private class DefaultRadioButtonColors(
private val selectedColor: Color,
private val unselectedColor: Color,
private val disabledColor: Color,
private val clock: AnimationClockObservable
) : RadioButtonColors {
private val lazyAnimatedColor = LazyAnimatedValue<Color, AnimationVector4D> { target ->
AnimatedValueModel(target, (Color.VectorConverter)(target.colorSpace), clock)
}
override fun radioColor(enabled: Boolean, selected: Boolean): Color {
val target = when {
!enabled -> disabledColor
!selected -> unselectedColor
else -> selectedColor
}
// If not enabled 'snap' to the disabled state, as there should be no animations between
// enabled / disabled.
return if (enabled) {
val animatedColor = lazyAnimatedColor.animatedValueForTarget(target)
if (animatedColor.targetValue != target) {
animatedColor.animateTo(target, tween(durationMillis = RadioAnimationDuration))
}
animatedColor.value
} else {
target
}
}
}
private const val RadioAnimationDuration = 100
private val RadioButtonRippleRadius = 24.dp
private val RadioButtonPadding = 2.dp
private val RadioButtonSize = 20.dp
private val RadioRadius = RadioButtonSize / 2
private val RadioButtonDotSize = 12.dp
private val RadioStrokeWidth = 2.dp