| /* |
| * Copyright 2019 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.ui.material |
| |
| import androidx.animation.AnimationEndReason |
| import androidx.animation.TargetAnimation |
| import androidx.animation.TweenBuilder |
| import androidx.annotation.IntRange |
| import androidx.compose.Composable |
| import androidx.compose.memo |
| import androidx.compose.state |
| import androidx.compose.unaryPlus |
| import androidx.ui.core.Alignment |
| import androidx.ui.core.Draw |
| import androidx.ui.core.Modifier |
| import androidx.ui.core.PxSize |
| import androidx.ui.core.WithConstraints |
| import androidx.ui.core.ambientDensity |
| import androidx.ui.core.dp |
| import androidx.ui.core.gesture.PressGestureDetector |
| import androidx.ui.core.px |
| import androidx.ui.core.toRect |
| import androidx.ui.core.withDensity |
| import androidx.ui.engine.geometry.Offset |
| import androidx.ui.foundation.animation.AnimatedValueHolder |
| import androidx.ui.foundation.animation.FlingConfig |
| import androidx.ui.foundation.gestures.DragDirection |
| import androidx.ui.foundation.gestures.Draggable |
| import androidx.ui.foundation.shape.corner.CircleShape |
| import androidx.ui.graphics.Canvas |
| import androidx.ui.graphics.Color |
| import androidx.ui.graphics.Paint |
| import androidx.ui.graphics.PaintingStyle |
| import androidx.ui.graphics.PointMode |
| import androidx.ui.graphics.StrokeCap |
| import androidx.ui.layout.Container |
| import androidx.ui.layout.DpConstraints |
| import androidx.ui.layout.Padding |
| import androidx.ui.layout.LayoutSize |
| import androidx.ui.layout.Spacer |
| import androidx.ui.lerp |
| import androidx.ui.material.ripple.Ripple |
| import androidx.ui.material.surface.Surface |
| import androidx.ui.semantics.Semantics |
| import androidx.ui.semantics.accessibilityValue |
| import kotlin.math.abs |
| |
| /** |
| * State for Slider that represents the Slider value, its bounds and optional amount of steps |
| * evenly distributed across the Slider range. |
| * |
| * @param initial initial value for the Slider when created. If outside of range provided, |
| * initial position will be coerced to this range |
| * @param valueRange range of values that Slider value can take |
| * @param steps if greater than 0, specifies the amounts of discrete values, evenly distributed |
| * between across the whole value range. If 0, slider will behave as a continuous slider and allow |
| * to choose any value from the range specified |
| */ |
| class SliderPosition( |
| initial: Float = 0f, |
| val valueRange: ClosedFloatingPointRange<Float> = 0f..1f, |
| @IntRange(from = 0) steps: Int = 0 |
| ) { |
| |
| internal val startValue: Float = valueRange.start |
| internal val endValue: Float = valueRange.endInclusive |
| |
| init { |
| require(steps >= 0) { |
| "steps should be >= 0" |
| } |
| } |
| |
| /** |
| * Current Slider value. If set outside of range provided, value will be coerced to this range |
| */ |
| var value: Float |
| get() = scale(startPx, endPx, holder.value, startValue, endValue) |
| set(value) { |
| holder.animatedFloat.snapTo(scale(startValue, endValue, value, startPx, endPx)) |
| } |
| |
| private var endPx = Float.MAX_VALUE |
| private var startPx = Float.MIN_VALUE |
| |
| internal fun setBounds(min: Float, max: Float) { |
| if (startPx == min && endPx == max) return |
| val newValue = scale(startPx, endPx, holder.value, min, max) |
| startPx = min |
| endPx = max |
| holder.setBounds(min, max) |
| anchorsPx = tickFractions.map { lerp(startPx, endPx, it) } |
| holder.animatedFloat.snapTo(newValue) |
| } |
| |
| internal val holder = AnimatedValueHolder(scale(startValue, endValue, initial, startPx, endPx)) |
| |
| internal val tickFractions: List<Float> = |
| if (steps == 0) emptyList() else List(steps + 2) { it.toFloat() / (steps + 1) } |
| |
| internal var anchorsPx: List<Float> = emptyList() |
| private set |
| } |
| |
| /** |
| * Sliders allow users to make selections from a range of values. |
| * |
| * Sliders reflect a range of values along a bar, from which users may select a single value. |
| * They are ideal for adjusting settings such as volume, brightness, or applying image filters. |
| * |
| * Use continuous sliders allow users to make meaningful selections that don’t |
| * require a specific value: |
| * |
| * @sample androidx.ui.material.samples.SliderSample |
| * |
| * You can allow the user to choose only between predefined set of values by providing |
| * discrete values in [SliderPosition]. |
| * |
| * You can do it by specifying the amount of steps between min and max values: |
| * |
| * @sample androidx.ui.material.samples.StepsSliderSample |
| * |
| * @param position [SliderPosition] object to represent value of the Slider |
| * @param onValueChange lambda in which value should be updated |
| * @param modifier modifiers for the Slider layout |
| * @param onValueChangeEnd lambda to be invoked when value change has ended. This callback |
| * shouldn't be used to update the slider value (use [onValueChange] for that), but rather to |
| * know when the user has completed selecting a new value by ending a drag or a click. |
| * @param color color of the Slider |
| */ |
| @Composable |
| fun Slider( |
| position: SliderPosition, |
| onValueChange: (Float) -> Unit = { position.value = it }, |
| modifier: Modifier = Modifier.None, |
| onValueChangeEnd: () -> Unit = {}, |
| color: Color = (+MaterialTheme.colors()).primary |
| ) { |
| Container(modifier = modifier) { |
| WithConstraints { constraints -> |
| val maxPx = constraints.maxWidth.value.toFloat() |
| val minPx = 0f |
| position.setBounds(minPx, maxPx) |
| |
| fun Float.toSliderPosition(): Float = |
| scale(minPx, maxPx, this, position.startValue, position.endValue) |
| |
| val flingConfig = |
| if (position.anchorsPx.isNotEmpty()) { |
| SliderFlingConfig(position, position.anchorsPx) { endValue -> |
| onValueChange(endValue.toSliderPosition()) |
| onValueChangeEnd() |
| } |
| } else { |
| null |
| } |
| val gestureEndAction = { velocity: Float -> |
| if (flingConfig != null) { |
| position.holder.fling(flingConfig, velocity) |
| } else { |
| onValueChangeEnd() |
| } |
| } |
| val pressed = +state { false } |
| PressGestureDetector( |
| onPress = { pos -> |
| onValueChange(pos.x.value.toSliderPosition()) |
| pressed.value = true |
| }, |
| onRelease = { |
| pressed.value = false |
| gestureEndAction(0f) |
| }) { |
| Draggable( |
| dragDirection = DragDirection.Horizontal, |
| dragValue = position.holder, |
| onDragStarted = { pressed.value = true }, |
| onDragValueChangeRequested = { onValueChange(it.toSliderPosition()) }, |
| onDragStopped = { velocity -> |
| pressed.value = false |
| gestureEndAction(velocity) |
| }, |
| children = { SliderImpl(position, color, maxPx, pressed.value) } |
| ) |
| } |
| } |
| } |
| } |
| |
| @Composable |
| private fun SliderImpl(position: SliderPosition, color: Color, width: Float, pressed: Boolean) { |
| val widthDp = withDensity(+ambientDensity()) { |
| width.px.toDp() |
| } |
| Semantics(container = true, properties = { accessibilityValue = "${position.value}" }) { |
| Container( |
| expanded = true, |
| constraints = DefaultSliderConstraints, |
| alignment = Alignment.CenterLeft |
| ) { |
| val thumbSize = ThumbRadius * 2 |
| val fraction = with(position) { calcFraction(startValue, endValue, this.value) } |
| val offset = (widthDp - thumbSize) * fraction |
| DrawTrack(color, position) |
| Padding(left = offset) { |
| Ripple(bounded = false) { |
| Surface( |
| shape = CircleShape, |
| color = color, |
| elevation = if (pressed) 6.dp else 1.dp |
| ) { |
| Spacer(LayoutSize(thumbSize, thumbSize)) |
| } |
| } |
| } |
| } |
| } |
| } |
| |
| @Composable |
| private fun DrawTrack( |
| color: Color, |
| position: SliderPosition |
| ) { |
| val activeTickColor = (+MaterialTheme.colors()).onPrimary.copy(alpha = TickColorAlpha) |
| val inactiveTickColor = color.copy(alpha = TickColorAlpha) |
| val paint = +memo { |
| Paint().apply { |
| this.isAntiAlias = true |
| this.strokeCap = StrokeCap.round |
| this.style = PaintingStyle.stroke |
| } |
| } |
| Draw { canvas: Canvas, parentSize: PxSize -> |
| paint.strokeWidth = TrackHeight.toPx().value |
| val fraction = with(position) { calcFraction(startValue, endValue, this.value) } |
| val parentRect = parentSize.toRect() |
| val thumbPx = ThumbRadius.toPx().value |
| val centerHeight = parentSize.height.value / 2 |
| val sliderStart = Offset(parentRect.left + thumbPx, centerHeight) |
| val sliderMax = Offset(parentRect.right - thumbPx, centerHeight) |
| paint.color = color.copy(alpha = InactiveTrackColorAlpha) |
| canvas.drawLine(sliderStart, sliderMax, paint) |
| val sliderValue = Offset( |
| sliderStart.dx + (sliderMax.dx - sliderStart.dx) * fraction, |
| centerHeight |
| ) |
| paint.color = color |
| canvas.drawLine(sliderStart, sliderValue, paint) |
| position.tickFractions.groupBy { it > fraction }.forEach { (afterFraction, list) -> |
| paint.color = if (afterFraction) inactiveTickColor else activeTickColor |
| val points = list.map { |
| Offset(Offset.lerp(sliderStart, sliderMax, it).dx, centerHeight) |
| } |
| canvas.drawPoints(PointMode.points, points, paint) |
| } |
| } |
| } |
| |
| // Scale x1 from a1..b1 range to a2..b2 range |
| private fun scale(a1: Float, b1: Float, x1: Float, a2: Float, b2: Float) = |
| lerp(a2, b2, calcFraction(a1, b1, x1)) |
| |
| // Calculate the 0..1 fraction that `pos` value represents between `a` and `b` |
| private fun calcFraction(a: Float, b: Float, pos: Float) = |
| (if (b - a == 0f) 0f else (pos - a) / (b - a)).coerceIn(0f, 1f) |
| |
| private fun SliderFlingConfig( |
| value: SliderPosition, |
| anchors: List<Float>, |
| onSuccessfulEnd: (Float) -> Unit |
| ): FlingConfig { |
| val adjustTarget: (Float) -> TargetAnimation? = { _ -> |
| val now = value.holder.value |
| val point = anchors.minBy { abs(it - now) } |
| val adjusted = point ?: now |
| TargetAnimation(adjusted, SliderToTickAnimation) |
| } |
| return FlingConfig( |
| adjustTarget = adjustTarget, |
| onAnimationEnd = { reason, endValue, _ -> |
| if (reason != AnimationEndReason.Interrupted) { |
| onSuccessfulEnd(endValue) |
| } |
| } |
| ) |
| } |
| |
| private val ThumbRadius = 10.dp |
| private val TrackHeight = 4.dp |
| private val SliderHeight = 48.dp |
| private val SliderMinWidth = 144.dp // TODO: clarify min width |
| private val DefaultSliderConstraints = |
| DpConstraints(minWidth = SliderMinWidth, maxHeight = SliderHeight) |
| private val InactiveTrackColorAlpha = 0.24f |
| private val TickColorAlpha = 0.54f |
| private val SliderToTickAnimation = TweenBuilder<Float>().apply { duration = 100 } |