| /* |
| * Copyright 2020 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.demos |
| |
| import android.graphics.SweepGradient |
| import androidx.compose.animation.core.FloatPropKey |
| import androidx.compose.animation.core.transitionDefinition |
| import androidx.compose.animation.core.tween |
| import androidx.compose.Composable |
| import androidx.compose.emptyContent |
| import androidx.compose.getValue |
| import androidx.compose.remember |
| import androidx.compose.setValue |
| import androidx.compose.state |
| import androidx.compose.animation.DpPropKey |
| import androidx.compose.animation.transition |
| import androidx.ui.core.DensityAmbient |
| import androidx.ui.core.Modifier |
| import androidx.ui.core.WithConstraints |
| import androidx.ui.core.drawOpacity |
| import androidx.ui.core.gesture.DragObserver |
| import androidx.ui.core.gesture.dragGestureFilter |
| import androidx.compose.foundation.Border |
| import androidx.compose.foundation.Box |
| import androidx.compose.foundation.ContentGravity |
| import androidx.compose.foundation.Image |
| import androidx.compose.foundation.Text |
| import androidx.compose.foundation.currentTextStyle |
| import androidx.compose.foundation.shape.GenericShape |
| import androidx.compose.foundation.shape.CircleShape |
| import androidx.compose.ui.geometry.Offset |
| import androidx.compose.ui.geometry.RRect |
| import androidx.compose.ui.geometry.Radius |
| import androidx.compose.ui.graphics.Canvas |
| import androidx.compose.ui.graphics.Color |
| import androidx.compose.ui.graphics.ImageAsset |
| import androidx.compose.ui.graphics.Paint |
| import androidx.compose.ui.graphics.SolidColor |
| import androidx.compose.ui.graphics.isSet |
| import androidx.compose.ui.graphics.toArgb |
| import androidx.compose.ui.graphics.toPixelMap |
| import androidx.compose.foundation.layout.Column |
| import androidx.compose.foundation.layout.Row |
| import androidx.compose.foundation.layout.Spacer |
| import androidx.compose.foundation.layout.Stack |
| import androidx.compose.foundation.layout.aspectRatio |
| import androidx.compose.foundation.layout.fillMaxHeight |
| import androidx.compose.foundation.layout.fillMaxSize |
| import androidx.compose.foundation.layout.fillMaxWidth |
| import androidx.compose.foundation.layout.offset |
| import androidx.compose.foundation.layout.padding |
| import androidx.compose.foundation.layout.preferredHeight |
| import androidx.compose.foundation.layout.preferredSize |
| import androidx.ui.material.Surface |
| import androidx.ui.material.TopAppBar |
| import androidx.compose.ui.text.style.TextAlign |
| import androidx.compose.ui.unit.Dp |
| import androidx.compose.ui.unit.dp |
| import java.util.Locale |
| |
| /** |
| * Demo that shows picking a color from a color wheel, which then dynamically updates |
| * the color of a [TopAppBar]. This pattern could also be used to update the value of a |
| * ColorPalette, updating the overall theme for an application. |
| */ |
| @Composable |
| fun ColorPickerDemo() { |
| var primary by state { Color(0xFF6200EE) } |
| Surface(color = Color(0xFF121212)) { |
| Column { |
| TopAppBar(title = { Text("Color Picker") }, backgroundColor = primary) |
| ColorPicker(onColorChange = { primary = it }) |
| } |
| } |
| } |
| |
| @Composable |
| private fun ColorPicker(onColorChange: (Color) -> Unit) { |
| WithConstraints( |
| Modifier.padding(50.dp) |
| .fillMaxSize() |
| .aspectRatio(1f) |
| ) { |
| val diameter = constraints.maxWidth |
| var position by state { Offset.Zero } |
| val colorWheel = remember(diameter) { ColorWheel(diameter) } |
| |
| var isDragging by state { false } |
| val inputModifier = SimplePointerInput( |
| position = position, |
| onPositionChange = { newPosition -> |
| // Work out if the new position is inside the circle we are drawing, and has a |
| // valid color associated to it. If not, keep the current position |
| val newColor = colorWheel.colorForPosition(newPosition) |
| if (newColor.isSet) { |
| position = newPosition |
| onColorChange(newColor) |
| } |
| }, |
| onDragStateChange = { isDragging = it } |
| ) |
| |
| Stack(Modifier.fillMaxSize()) { |
| Image(modifier = inputModifier, asset = colorWheel.image) |
| val color = colorWheel.colorForPosition(position) |
| if (color.isSet) { |
| Magnifier(visible = isDragging, position = position, color = color) |
| } |
| } |
| } |
| } |
| |
| // TODO: b/152046065 dragging has the wrong semantics here, and it's possible to continue dragging |
| // outside the bounds of the layout. Use a higher level, simple input wrapper when it's available |
| // to just get the current position of the pointer, without needing to care about drag behavior / |
| // relative positions. |
| /** |
| * [dragGestureFilter] that only cares about raw positions, where [position] is the position of |
| * the current / last input event, [onPositionChange] is called with the new position when the |
| * pointer moves, and [onDragStateChange] is called when dragging starts / stops. |
| */ |
| @Composable |
| private fun SimplePointerInput( |
| position: Offset, |
| onPositionChange: (Offset) -> Unit, |
| onDragStateChange: (Boolean) -> Unit |
| ): Modifier { |
| val observer = object : DragObserver { |
| override fun onStart(downPosition: Offset) { |
| onDragStateChange(true) |
| onPositionChange(downPosition) |
| } |
| |
| override fun onDrag(dragDistance: Offset): Offset { |
| onPositionChange(position + dragDistance) |
| return dragDistance |
| } |
| |
| override fun onCancel() { |
| onDragStateChange(false) |
| } |
| |
| override fun onStop(velocity: Offset) { |
| onDragStateChange(false) |
| } |
| } |
| |
| return Modifier.dragGestureFilter(observer, startDragImmediately = true) |
| } |
| |
| /** |
| * Magnifier displayed on top of [position] with the currently selected [color]. |
| */ |
| @Composable |
| private fun Magnifier(visible: Boolean, position: Offset, color: Color) { |
| val offset = with(DensityAmbient.current) { |
| Modifier.offset( |
| position.x.toDp() - MagnifierWidth / 2, |
| // Align with the center of the selection circle |
| position.y.toDp() - (MagnifierHeight - (SelectionCircleDiameter / 2)) |
| ) |
| } |
| MagnifierTransition( |
| visible, |
| MagnifierWidth, |
| SelectionCircleDiameter |
| ) { labelWidth: Dp, selectionDiameter: Dp, |
| opacity: Float -> |
| Column( |
| offset.preferredSize(width = MagnifierWidth, height = MagnifierHeight) |
| .drawOpacity(opacity) |
| ) { |
| Box(Modifier.fillMaxWidth(), gravity = ContentGravity.Center) { |
| MagnifierLabel(Modifier.preferredSize(labelWidth, MagnifierLabelHeight), color) |
| } |
| Spacer(Modifier.weight(1f)) |
| Box( |
| Modifier.fillMaxWidth().preferredHeight(SelectionCircleDiameter), |
| gravity = ContentGravity.Center |
| ) { |
| MagnifierSelectionCircle(Modifier.preferredSize(selectionDiameter), color) |
| } |
| } |
| } |
| } |
| |
| private val MagnifierWidth = 110.dp |
| private val MagnifierHeight = 100.dp |
| private val MagnifierLabelHeight = 50.dp |
| private val SelectionCircleDiameter = 30.dp |
| |
| /** |
| * [transition] that animates between [visible] states of the magnifier by animating the width of |
| * the label, diameter of the selection circle, and opacity of the overall magnifier |
| */ |
| @Composable |
| private fun MagnifierTransition( |
| visible: Boolean, |
| maxWidth: Dp, |
| maxDiameter: Dp, |
| children: @Composable (labelWidth: Dp, selectionDiameter: Dp, opacity: Float) -> Unit |
| ) { |
| val transitionDefinition = remember { |
| transitionDefinition { |
| state(false) { |
| this[LabelWidthPropKey] = 0.dp |
| this[MagnifierDiameterPropKey] = 0.dp |
| this[OpacityPropKey] = 0f |
| } |
| state(true) { |
| this[LabelWidthPropKey] = maxWidth |
| this[MagnifierDiameterPropKey] = maxDiameter |
| this[OpacityPropKey] = 1f |
| } |
| transition(false to true) { |
| LabelWidthPropKey using tween() |
| MagnifierDiameterPropKey using tween() |
| OpacityPropKey using tween() |
| } |
| transition(true to false) { |
| LabelWidthPropKey using tween() |
| MagnifierDiameterPropKey using tween() |
| OpacityPropKey using tween( |
| delayMillis = 100, |
| durationMillis = 200 |
| ) |
| } |
| } |
| } |
| val state = transition(transitionDefinition, visible) |
| children(state[LabelWidthPropKey], state[MagnifierDiameterPropKey], state[OpacityPropKey]) |
| } |
| |
| private val LabelWidthPropKey = DpPropKey() |
| private val MagnifierDiameterPropKey = DpPropKey() |
| private val OpacityPropKey = FloatPropKey() |
| |
| /** |
| * Label representing the currently selected [color], with [Text] representing the hex code and a |
| * square at the start showing the [color]. |
| */ |
| @Composable |
| private fun MagnifierLabel(modifier: Modifier, color: Color) { |
| Surface(shape = MagnifierPopupShape, elevation = 4.dp) { |
| Row(modifier) { |
| Box(Modifier.weight(0.25f).fillMaxHeight(), backgroundColor = color) |
| // Add `#` and drop alpha characters |
| val text = "#" + Integer.toHexString(color.toArgb()).toUpperCase(Locale.ROOT).drop(2) |
| val textStyle = currentTextStyle().copy(textAlign = TextAlign.Center) |
| Text( |
| text = text, |
| modifier = Modifier.weight(0.75f).padding(top = 10.dp, bottom = 20.dp), |
| style = textStyle, |
| maxLines = 1 |
| ) |
| } |
| } |
| } |
| |
| /** |
| * Selection circle drawn over the currently selected pixel of the color wheel. |
| */ |
| @Composable |
| private fun MagnifierSelectionCircle(modifier: Modifier, color: Color) { |
| Surface( |
| modifier, |
| shape = CircleShape, |
| elevation = 4.dp, |
| color = color, |
| border = Border(2.dp, SolidColor(Color.Black.copy(alpha = 0.75f))), |
| content = emptyContent() |
| ) |
| } |
| |
| /** |
| * A [GenericShape] that draws a box with a triangle at the bottom center to indicate a popup. |
| */ |
| private val MagnifierPopupShape = GenericShape { size -> |
| val width = size.width |
| val height = size.height |
| |
| val arrowY = height * 0.8f |
| val arrowXOffset = width * 0.4f |
| |
| addRRect(RRect(0f, 0f, width, arrowY, radius = Radius(20f, 20f))) |
| |
| moveTo(arrowXOffset, arrowY) |
| lineTo(width / 2f, height) |
| lineTo(width - arrowXOffset, arrowY) |
| close() |
| } |
| |
| /** |
| * A color wheel with an [ImageAsset] that draws a circular color wheel of the specified diameter. |
| */ |
| private class ColorWheel(diameter: Int) { |
| private val radius = diameter / 2f |
| |
| // TODO: b/152063545 - replace with Compose SweepGradient when it is available |
| private val sweepShader = SweepGradient( |
| radius, |
| radius, |
| intArrayOf( |
| android.graphics.Color.RED, |
| android.graphics.Color.MAGENTA, |
| android.graphics.Color.BLUE, |
| android.graphics.Color.CYAN, |
| android.graphics.Color.GREEN, |
| android.graphics.Color.YELLOW, |
| android.graphics.Color.RED |
| ), |
| null |
| ) |
| |
| val image = ImageAsset(diameter, diameter).also { asset -> |
| val canvas = Canvas(asset) |
| val center = Offset(radius, radius) |
| val paint = Paint().apply { shader = sweepShader } |
| canvas.drawCircle(center, radius, paint) |
| } |
| } |
| |
| /** |
| * @return the matching color for [position] inside [ColorWheel], or `null` if there is no color |
| * or the color is partially transparent. |
| */ |
| private fun ColorWheel.colorForPosition(position: Offset): Color { |
| val x = position.x.toInt().coerceAtLeast(0) |
| val y = position.y.toInt().coerceAtLeast(0) |
| with(image.toPixelMap()) { |
| if (x >= width || y >= height) return Color.Unset |
| return this[x, y].takeIf { it.alpha == 1f } ?: Color.Unset |
| } |
| } |