| /* |
| * 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.compose.foundation.demos |
| |
| import android.graphics.Matrix |
| import androidx.compose.foundation.BorderStroke |
| import androidx.compose.foundation.background |
| import androidx.compose.foundation.border |
| import androidx.compose.foundation.gestures.awaitEachGesture |
| import androidx.compose.foundation.gestures.detectDragGestures |
| import androidx.compose.foundation.gestures.detectHorizontalDragGestures |
| import androidx.compose.foundation.gestures.detectTapGestures |
| import androidx.compose.foundation.gestures.detectTransformGestures |
| import androidx.compose.foundation.gestures.detectVerticalDragGestures |
| import androidx.compose.foundation.layout.Box |
| import androidx.compose.foundation.layout.Column |
| import androidx.compose.foundation.layout.Row |
| import androidx.compose.foundation.layout.Spacer |
| import androidx.compose.foundation.layout.fillMaxHeight |
| import androidx.compose.foundation.layout.fillMaxSize |
| import androidx.compose.foundation.layout.fillMaxWidth |
| import androidx.compose.foundation.layout.height |
| import androidx.compose.foundation.layout.offset |
| import androidx.compose.foundation.layout.requiredHeight |
| import androidx.compose.foundation.layout.requiredSize |
| import androidx.compose.foundation.layout.requiredWidth |
| import androidx.compose.foundation.layout.size |
| import androidx.compose.integration.demos.common.ComposableDemo |
| import androidx.compose.material.Text |
| import androidx.compose.runtime.Composable |
| import androidx.compose.runtime.getValue |
| import androidx.compose.runtime.mutableFloatStateOf |
| import androidx.compose.runtime.mutableStateOf |
| import androidx.compose.runtime.remember |
| import androidx.compose.runtime.setValue |
| import androidx.compose.ui.Alignment |
| import androidx.compose.ui.Modifier |
| import androidx.compose.ui.draw.clipToBounds |
| import androidx.compose.ui.draw.drawBehind |
| import androidx.compose.ui.geometry.Offset |
| import androidx.compose.ui.geometry.Size |
| import androidx.compose.ui.graphics.Color |
| import androidx.compose.ui.graphics.TransformOrigin |
| import androidx.compose.ui.graphics.graphicsLayer |
| import androidx.compose.ui.input.pointer.PointerInputScope |
| import androidx.compose.ui.input.pointer.PointerType |
| import androidx.compose.ui.input.pointer.pointerInput |
| import androidx.compose.ui.layout.onSizeChanged |
| import androidx.compose.ui.unit.IntOffset |
| import androidx.compose.ui.unit.IntSize |
| import androidx.compose.ui.unit.dp |
| import kotlin.math.atan2 |
| import kotlin.math.roundToInt |
| import kotlin.math.sqrt |
| import kotlin.random.Random |
| |
| val CoroutineGestureDemos = listOf( |
| ComposableDemo("Tap/Double-Tap/Long Press") { CoroutineTapDemo() }, |
| ComposableDemo("Drag Horizontal and Vertical") { TouchSlopDragGestures() }, |
| ComposableDemo("Drag with orientation locking") { OrientationLockDragGestures() }, |
| ComposableDemo("Drag 2D") { Drag2DGestures() }, |
| ComposableDemo("Rotation/Pan/Zoom") { MultitouchGestureDetector() }, |
| ComposableDemo("Rotation/Pan/Zoom with Lock") { MultitouchLockGestureDetector() }, |
| ComposableDemo("Pointer type input") { PointerTypeInput() }, |
| ) |
| |
| fun hueToColor(hue: Float): Color { |
| val huePrime = hue / 60 |
| val hueRange = huePrime.toInt() |
| val hueRemainder = huePrime - hueRange |
| return when (hueRange) { |
| 0 -> Color(1f, hueRemainder, 0f) |
| 1 -> Color(1f - hueRemainder, 1f, 0f) |
| 2 -> Color(0f, 1f, hueRemainder) |
| 3 -> Color(0f, 1f - hueRemainder, 1f) |
| 4 -> Color(hueRemainder, 0f, 1f) |
| else -> Color(1f, 0f, 1f - hueRemainder) |
| } |
| } |
| |
| fun randomHue() = Random.nextFloat() * 360 |
| |
| fun anotherRandomHue(hue: Float): Float { |
| val newHue: Float = Random.nextFloat() * 260f |
| |
| // we don't want the hue to be close, so we ensure that it isn't with 50 of the current hue |
| return if (newHue > hue - 50f) { |
| newHue + 100f |
| } else { |
| newHue |
| } |
| } |
| |
| /** |
| * Gesture detector for tap, double-tap, and long-press. |
| */ |
| @Composable |
| fun CoroutineTapDemo() { |
| var tapHue by remember { mutableFloatStateOf(randomHue()) } |
| var longPressHue by remember { mutableFloatStateOf(randomHue()) } |
| var doubleTapHue by remember { mutableFloatStateOf(randomHue()) } |
| var pressHue by remember { mutableFloatStateOf(randomHue()) } |
| var releaseHue by remember { mutableFloatStateOf(randomHue()) } |
| var cancelHue by remember { mutableFloatStateOf(randomHue()) } |
| |
| Column { |
| Text("The boxes change color when you tap the white box.") |
| Spacer(Modifier.requiredSize(5.dp)) |
| Box( |
| Modifier |
| .fillMaxWidth() |
| .height(50.dp) |
| .pointerInput(Unit) { |
| detectTapGestures( |
| onTap = { tapHue = anotherRandomHue(tapHue) }, |
| onDoubleTap = { doubleTapHue = anotherRandomHue(doubleTapHue) }, |
| onLongPress = { longPressHue = anotherRandomHue(longPressHue) }, |
| onPress = { |
| pressHue = anotherRandomHue(pressHue) |
| if (tryAwaitRelease()) { |
| releaseHue = anotherRandomHue(releaseHue) |
| } else { |
| cancelHue = anotherRandomHue(cancelHue) |
| } |
| } |
| ) |
| } |
| .background(Color.White) |
| .border(BorderStroke(2.dp, Color.Black)) |
| ) { |
| Text("Tap, double-tap, or long-press", Modifier.align(Alignment.Center)) |
| } |
| Spacer(Modifier.requiredSize(5.dp)) |
| Row { |
| Box( |
| Modifier |
| .size(50.dp) |
| .background(hueToColor(tapHue)) |
| .border(BorderStroke(2.dp, Color.Black)) |
| ) |
| Text("Changes color on tap", Modifier.align(Alignment.CenterVertically)) |
| } |
| Spacer(Modifier.requiredSize(5.dp)) |
| Row { |
| Box( |
| Modifier |
| .size(50.dp) |
| .clipToBounds() |
| .background(hueToColor(doubleTapHue)) |
| .border(BorderStroke(2.dp, Color.Black)) |
| ) |
| Text("Changes color on double-tap", Modifier.align(Alignment.CenterVertically)) |
| } |
| Spacer(Modifier.requiredSize(5.dp)) |
| Row { |
| Box( |
| Modifier |
| .size(50.dp) |
| .clipToBounds() |
| .background(hueToColor(longPressHue)) |
| .border(BorderStroke(2.dp, Color.Black)) |
| ) |
| Text("Changes color on long press", Modifier.align(Alignment.CenterVertically)) |
| } |
| Spacer(Modifier.requiredSize(5.dp)) |
| Row { |
| Box( |
| Modifier |
| .size(50.dp) |
| .clipToBounds() |
| .background(hueToColor(pressHue)) |
| .border(BorderStroke(2.dp, Color.Black)) |
| ) |
| Text("Changes color on press", Modifier.align(Alignment.CenterVertically)) |
| } |
| Spacer(Modifier.requiredSize(5.dp)) |
| Row { |
| Box( |
| Modifier |
| .size(50.dp) |
| .clipToBounds() |
| .background(hueToColor(releaseHue)) |
| .border(BorderStroke(2.dp, Color.Black)) |
| ) |
| Text("Changes color on release", Modifier.align(Alignment.CenterVertically)) |
| } |
| Spacer(Modifier.requiredSize(5.dp)) |
| Row { |
| Box( |
| Modifier |
| .size(50.dp) |
| .clipToBounds() |
| .background(hueToColor(cancelHue)) |
| .border(BorderStroke(2.dp, Color.Black)) |
| ) |
| Text("Changes color on cancel", Modifier.align(Alignment.CenterVertically)) |
| } |
| } |
| } |
| |
| @Composable |
| fun TouchSlopDragGestures() { |
| Column { |
| var width by remember { mutableFloatStateOf(0f) } |
| Box( |
| Modifier.fillMaxWidth() |
| .background(Color.Cyan) |
| .onSizeChanged { width = it.width.toFloat() } |
| ) { |
| var offset by remember { mutableStateOf(0.dp) } |
| Box( |
| Modifier.offset { IntOffset(x = offset.roundToPx(), y = 0) } |
| .requiredSize(50.dp) |
| .background(Color.Blue) |
| .pointerInput(Unit) { |
| detectHorizontalDragGestures { _, dragDistance -> |
| val offsetPx = offset.toPx() |
| val newOffset = |
| (offsetPx + dragDistance).coerceIn(0f, width - 50.dp.toPx()) |
| val consumed = newOffset - offsetPx |
| if (consumed != 0f) { |
| offset = newOffset.toDp() |
| } |
| } |
| } |
| ) |
| Text("Drag blue box within here", Modifier.align(Alignment.Center)) |
| } |
| |
| Box(Modifier.weight(1f)) { |
| var height by remember { mutableFloatStateOf(0f) } |
| Box( |
| Modifier.fillMaxHeight() |
| .background(Color.Yellow) |
| .onSizeChanged { height = it.height.toFloat() } |
| ) { |
| var offset by remember { mutableStateOf(0.dp) } |
| Box( |
| Modifier.offset { IntOffset(x = 0, y = offset.roundToPx()) } |
| .requiredSize(50.dp) |
| .background(Color.Red) |
| .pointerInput(Unit) { |
| detectVerticalDragGestures { _, dragDistance -> |
| val offsetPx = offset.toPx() |
| val newOffset = (offsetPx + dragDistance) |
| .coerceIn(0f, height - 50.dp.toPx()) |
| val consumed = newOffset - offsetPx |
| if (consumed != 0f) { |
| offset = newOffset.toDp() |
| } |
| } |
| } |
| ) |
| } |
| Box( |
| Modifier.requiredHeight(50.dp) |
| .fillMaxWidth() |
| .align(Alignment.TopStart) |
| .graphicsLayer( |
| rotationZ = 90f, |
| transformOrigin = TransformOrigin(0f, 1f) |
| ) |
| ) { |
| Text( |
| "Drag red box within here", |
| Modifier.align(Alignment.Center) |
| ) |
| } |
| } |
| } |
| } |
| |
| @Composable |
| fun OrientationLockDragGestures() { |
| var size by remember { mutableStateOf(IntSize.Zero) } |
| var offsetX by remember { mutableStateOf(0.dp) } |
| var offsetY by remember { mutableStateOf(0.dp) } |
| Box( |
| Modifier.onSizeChanged { |
| size = it |
| }.pointerInput(Unit) { |
| detectVerticalDragGestures { _, dragAmount -> |
| offsetY = (offsetY.toPx() + dragAmount) |
| .coerceIn(0f, size.height.toFloat() - 50.dp.toPx()).toDp() |
| } |
| } |
| |
| ) { |
| Box( |
| Modifier.offset { IntOffset(x = offsetX.roundToPx(), y = 0) } |
| .background(Color.Blue.copy(alpha = 0.5f)) |
| .requiredWidth(50.dp) |
| .fillMaxHeight() |
| .pointerInput(Unit) { |
| detectHorizontalDragGestures { _, dragAmount -> |
| offsetX = (offsetX.toPx() + dragAmount) |
| .coerceIn(0f, size.width.toFloat() - 50.dp.toPx()).toDp() |
| } |
| } |
| ) |
| Box( |
| Modifier.offset { IntOffset(x = 0, y = offsetY.roundToPx()) } |
| .background(Color.Red.copy(alpha = 0.5f)) |
| .requiredHeight(50.dp) |
| .fillMaxWidth() |
| ) |
| |
| Text( |
| "Drag the columns around. They should lock to vertical or horizontal.", |
| Modifier.align(Alignment.Center) |
| ) |
| } |
| } |
| |
| @Composable |
| fun Drag2DGestures() { |
| var size by remember { mutableStateOf(IntSize.Zero) } |
| var offsetX by remember { mutableFloatStateOf(0f) } |
| var offsetY by remember { mutableFloatStateOf(0f) } |
| Box( |
| Modifier.onSizeChanged { |
| size = it |
| }.fillMaxSize() |
| ) { |
| Box( |
| Modifier.offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) } |
| .background(Color.Blue) |
| .requiredSize(50.dp) |
| .pointerInput(Unit) { |
| detectDragGestures { _, dragAmount -> |
| offsetX = (offsetX + dragAmount.x) |
| .coerceIn(0f, size.width.toFloat() - 50.dp.toPx()) |
| |
| offsetY = (offsetY + dragAmount.y) |
| .coerceIn(0f, size.height.toFloat() - 50.dp.toPx()) |
| } |
| } |
| ) |
| Text("Drag the box around", Modifier.align(Alignment.Center)) |
| } |
| } |
| |
| @Composable |
| fun MultitouchArea( |
| text: String, |
| gestureDetector: suspend PointerInputScope.( |
| (centroid: Offset, pan: Offset, zoom: Float, angle: Float) -> Unit, |
| ) -> Unit |
| ) { |
| val matrix by remember { mutableStateOf(Matrix()) } |
| var angle by remember { mutableFloatStateOf(0f) } |
| var zoom by remember { mutableFloatStateOf(1f) } |
| var offsetX by remember { mutableFloatStateOf(0f) } |
| var offsetY by remember { mutableFloatStateOf(0f) } |
| |
| Box( |
| Modifier.fillMaxSize().pointerInput(Unit) { |
| gestureDetector { centroid, pan, gestureZoom, gestureAngle -> |
| val anchorX = centroid.x - size.width / 2f |
| val anchorY = centroid.y - size.height / 2f |
| matrix.postRotate(gestureAngle, anchorX, anchorY) |
| matrix.postScale(gestureZoom, gestureZoom, anchorX, anchorY) |
| matrix.postTranslate(pan.x, pan.y) |
| |
| val v = FloatArray(9) |
| matrix.getValues(v) |
| offsetX = v[Matrix.MTRANS_X] |
| offsetY = v[Matrix.MTRANS_Y] |
| val scaleX = v[Matrix.MSCALE_X] |
| val skewY = v[Matrix.MSKEW_Y] |
| zoom = sqrt(scaleX * scaleX + skewY * skewY) |
| angle = atan2(v[Matrix.MSKEW_X], v[Matrix.MSCALE_X]) * (-180 / Math.PI.toFloat()) |
| } |
| } |
| ) { |
| Box( |
| Modifier.offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) } |
| .graphicsLayer( |
| scaleX = zoom, |
| scaleY = zoom, |
| rotationZ = angle |
| ).drawBehind { |
| val approximateRectangleSize = 10.dp.toPx() |
| val numRectanglesHorizontal = |
| (size.width / approximateRectangleSize).roundToInt() |
| val numRectanglesVertical = |
| (size.height / approximateRectangleSize).roundToInt() |
| val rectangleWidth = size.width / numRectanglesHorizontal |
| val rectangleHeight = size.height / numRectanglesVertical |
| var hue = 0f |
| val rectangleSize = Size(rectangleWidth, rectangleHeight) |
| for (x in 0 until numRectanglesHorizontal) { |
| for (y in 0 until numRectanglesVertical) { |
| hue += 30 |
| if (hue >= 360f) { |
| hue = 0f |
| } |
| val color = hueToColor(hue) |
| val topLeft = Offset( |
| x = x * size.width / numRectanglesHorizontal, |
| y = y * size.height / numRectanglesVertical |
| ) |
| drawRect(color = color, topLeft = topLeft, size = rectangleSize) |
| } |
| } |
| } |
| .fillMaxSize() |
| ) |
| Text(text) |
| } |
| } |
| |
| /** |
| * This is a multi-touch gesture detector, including pan, zoom, and rotation. |
| * The user can pan, zoom, and rotate once touch slop has been reached. |
| */ |
| @Composable |
| fun MultitouchGestureDetector() { |
| MultitouchArea( |
| "Zoom, Pan, and Rotate" |
| ) { |
| detectTransformGestures( |
| panZoomLock = false, |
| onGesture = it |
| ) |
| } |
| } |
| |
| /** |
| * This is a multi-touch gesture detector, including pan, zoom, and rotation. |
| * It is common to want to lean toward zoom over rotation, so this gesture detector will |
| * lock into zoom if the first unless the rotation passes touch slop first. |
| */ |
| @Composable |
| fun MultitouchLockGestureDetector() { |
| MultitouchArea( |
| "Zoom, Pan, and Rotate Locking to Zoom" |
| ) { |
| detectTransformGestures( |
| panZoomLock = true, |
| onGesture = it |
| ) |
| } |
| } |
| |
| @Composable |
| fun PointerTypeInput() { |
| var pointerType by remember { mutableStateOf<PointerType?>(null) } |
| Box( |
| Modifier.pointerInput(Unit) { |
| awaitEachGesture { |
| val pointer = awaitPointerEvent().changes.first() |
| pointerType = pointer.type |
| do { |
| val event = awaitPointerEvent() |
| } while (event.changes.first().pressed) |
| pointerType = null |
| } |
| } |
| ) { |
| Text("Touch or click the area to see what type of input it is.") |
| Text("PointerType: ${pointerType ?: ""}", Modifier.align(Alignment.BottomStart)) |
| } |
| } |