| /* |
| * 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.graphics.shapes.testcompose |
| |
| import android.graphics.Matrix |
| import android.graphics.PointF |
| import android.util.Log |
| import androidx.compose.foundation.background |
| import androidx.compose.foundation.border |
| import androidx.compose.foundation.layout.Column |
| import androidx.compose.foundation.layout.PaddingValues |
| import androidx.compose.foundation.layout.Row |
| import androidx.compose.foundation.layout.Spacer |
| import androidx.compose.foundation.layout.fillMaxSize |
| import androidx.compose.foundation.layout.fillMaxWidth |
| import androidx.compose.foundation.layout.height |
| import androidx.compose.foundation.layout.padding |
| import androidx.compose.foundation.layout.width |
| import androidx.compose.material.Button |
| import androidx.compose.material.Slider |
| import androidx.compose.material.Text |
| import androidx.compose.runtime.Composable |
| import androidx.compose.runtime.MutableState |
| import androidx.compose.runtime.derivedStateOf |
| import androidx.compose.runtime.getValue |
| 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.graphics.Color |
| import androidx.compose.ui.unit.dp |
| import androidx.core.graphics.plus |
| import androidx.graphics.shapes.CornerRounding |
| import androidx.graphics.shapes.RoundedPolygon |
| import androidx.graphics.shapes.Star |
| import kotlin.math.cos |
| import kotlin.math.max |
| import kotlin.math.roundToInt |
| import kotlin.math.sin |
| |
| private val LOG_TAG = "ShapeEditor" |
| private val DEBUG = false |
| |
| internal fun debugLog(message: String) { |
| if (DEBUG) Log.d(LOG_TAG, message) |
| } |
| |
| data class ShapeItem( |
| val name: String, |
| val shapegen: () -> RoundedPolygon, |
| val debugDump: () -> Unit, |
| val usesSides: Boolean = true, |
| val usesInnerRatio: Boolean = true, |
| val usesRoundness: Boolean = true, |
| val usesInnerParameters: Boolean = true |
| ) |
| |
| private val PointZero = PointF(0f, 0f) |
| |
| class ShapeParameters( |
| sides: Int = 5, |
| innerRadiusRatio: Float = 0.5f, |
| roundness: Float = 0f, |
| smooth: Float = 0f, |
| innerRoundness: Float = roundness, |
| innerSmooth: Float = smooth, |
| rotation: Float = 0f, |
| shapeId: ShapeId = ShapeId.Polygon |
| ) { |
| internal val sides = mutableStateOf(sides.toFloat()) |
| internal val innerRadiusRatio = mutableStateOf(innerRadiusRatio) |
| internal val roundness = mutableStateOf(roundness) |
| internal val smooth = mutableStateOf(smooth) |
| internal val innerRoundness = mutableStateOf(innerRoundness) |
| internal val innerSmooth = mutableStateOf(innerSmooth) |
| internal val rotation = mutableStateOf(rotation) |
| |
| internal var shapeIx by mutableStateOf(shapeId.ordinal) |
| |
| fun copy() = ShapeParameters( |
| this.sides.value.roundToInt(), |
| this.innerRadiusRatio.value, |
| this.roundness.value, |
| this.smooth.value, |
| this.innerRoundness.value, |
| this.innerSmooth.value, |
| this.rotation.value, |
| ShapeId.values()[this.shapeIx] |
| ) |
| |
| enum class ShapeId { |
| Star, Polygon, Triangle, Blob, CornerSE |
| } |
| |
| private fun radialToCartesian( |
| radius: Float, |
| angleRadians: Float, |
| center: PointF = PointZero |
| ) = directionVectorPointF(angleRadians) * radius + center |
| |
| private fun rotationAsString() = |
| if (this.rotation.value != 0f) |
| "rotation = ${this.rotation.value}f, " |
| else |
| "" |
| |
| // Primitive shapes we can draw (so far) |
| internal val shapes = listOf( |
| ShapeItem("Star", shapegen = { |
| Star( |
| numOuterVertices = this.sides.value.roundToInt(), |
| innerRadiusRatio = this.innerRadiusRatio.value, |
| rounding = CornerRounding(this.roundness.value, this.smooth.value), |
| innerRounding = CornerRounding( |
| this.innerRoundness.value, |
| this.innerSmooth.value |
| ) |
| ) |
| }, |
| debugDump = { |
| debugLog( |
| "ShapeParameters(sides = ${this.sides.value.roundToInt()}, " + |
| "innerRadiusRatio = ${this.innerRadiusRatio.value}f, " + |
| "roundness = ${this.roundness.value}f, " + |
| "smooth = ${this.smooth.value}f, " + |
| "innerRoundness = ${this.innerRoundness.value}f, " + |
| "innerSmooth = ${this.innerSmooth.value}f, " + |
| rotationAsString() + |
| "shapeId = ShapeParameters.ShapeId.Star)" |
| ) |
| } |
| ), |
| ShapeItem("Polygon", shapegen = { |
| RoundedPolygon( |
| numVertices = this.sides.value.roundToInt(), |
| rounding = CornerRounding(this.roundness.value, this.smooth.value), |
| ) |
| }, |
| debugDump = { |
| debugLog( |
| "ShapeParameters(sides = ${this.sides.value.roundToInt()}, " + |
| "roundness = ${this.roundness.value}f, " + |
| "smooth = ${this.smooth.value}f, " + |
| rotationAsString() + |
| ")" |
| ) |
| }, usesInnerRatio = false, usesInnerParameters = false |
| ), |
| ShapeItem( |
| "Triangle", shapegen = { |
| val points = listOf( |
| radialToCartesian(1f, 270f.toRadians()), |
| radialToCartesian(1f, 30f.toRadians()), |
| radialToCartesian(this.innerRadiusRatio.value, 90f.toRadians()), |
| radialToCartesian(1f, 150f.toRadians()), |
| ) |
| RoundedPolygon( |
| points, |
| CornerRounding(this.roundness.value, this.smooth.value), |
| center = PointZero |
| ) |
| }, |
| debugDump = { |
| debugLog( |
| "ShapeParameters(innerRadiusRatio = ${this.innerRadiusRatio.value}f, " + |
| "smooth = ${this.smooth.value}f, " + |
| rotationAsString() + |
| "shapeId = ShapeParameters.ShapeId.Triangle)" |
| ) |
| }, |
| usesSides = false, usesInnerParameters = false |
| ), |
| ShapeItem( |
| "Blob", shapegen = { |
| val sx = this.innerRadiusRatio.value.coerceAtLeast(0.1f) |
| val sy = this.roundness.value.coerceAtLeast(0.1f) |
| RoundedPolygon( |
| listOf( |
| PointF(-sx, -sy), |
| PointF(sx, -sy), |
| PointF(sx, sy), |
| PointF(-sx, sy), |
| ), |
| rounding = CornerRounding(this.roundness.value, this.smooth.value), |
| center = PointZero |
| ) |
| }, |
| debugDump = { |
| debugLog( |
| "ShapeParameters(roundness = ${this.roundness.value}f, " + |
| "smooth = ${this.smooth.value}f, " + |
| rotationAsString() + |
| "shapeId = ShapeParameters.ShapeId.Blob)" |
| ) |
| }, |
| usesSides = false, usesInnerParameters = false |
| ), |
| ShapeItem( |
| "CornerSE", shapegen = { |
| RoundedPolygon( |
| SquarePoints(), |
| perVertexRounding = listOf( |
| CornerRounding(this.roundness.value, this.smooth.value), |
| CornerRounding(1f), |
| CornerRounding(1f), |
| CornerRounding(1f) |
| ), |
| center = PointZero |
| ) |
| }, |
| debugDump = { |
| debugLog( |
| "ShapeParameters(roundness = ${this.roundness.value}f, " + |
| "smooth = ${this.smooth.value}f, " + |
| rotationAsString() + |
| "shapeId = ShapeParameters.ShapeId.CornerSE)" |
| ) |
| }, |
| usesSides = false, |
| usesInnerRatio = false, |
| usesInnerParameters = false |
| ) |
| |
| /* |
| TODO: Add quarty. Needs to be able to specify a rounding radius of up to 2f |
| ShapeItem("Quarty", { DefaultShapes.quarty(roundness.value, smooth.value) }, |
| usesSides = false, usesInnerRatio = false), |
| */ |
| ) |
| |
| fun selectedShape() = derivedStateOf { shapes[shapeIx] } |
| |
| fun genShape(autoSize: Boolean = true) = selectedShape().value.shapegen().apply { |
| transform(Matrix().apply { |
| if (autoSize) { |
| // Move the center to the origin. |
| center |
| postTranslate(-(bounds.left + bounds.right) / 2, -(bounds.top + bounds.bottom) / 2) |
| |
| // Scale to the [-1, 1] range |
| val scale = 2f / max(bounds.width(), bounds.height()) |
| postScale(scale, scale) |
| } |
| // Apply the needed rotation |
| postRotate(rotation.value) |
| }) |
| } |
| } |
| |
| @Composable |
| fun ShapeEditor(params: ShapeParameters, onClose: () -> Unit) { |
| val shapeParams = params.selectedShape().value |
| var debug by remember { mutableStateOf(false) } |
| var autoSize by remember { mutableStateOf(true) } |
| |
| Column( |
| Modifier |
| .fillMaxSize() |
| .background(Color.Black) |
| ) { |
| Row(verticalAlignment = Alignment.CenterVertically) { |
| Text("Base Shape:", color = Color.White) |
| Spacer(Modifier.width(10.dp)) |
| Button(onClick = { params.shapeIx = (params.shapeIx + 1) % params.shapes.size }) { |
| Text(params.selectedShape().value.name) |
| } |
| } |
| MySlider("Sides", 3f, 20f, 1f, params.sides, shapeParams.usesSides) |
| MySlider( |
| "InnerRadiusRatio", |
| 0.1f, |
| 0.999f, |
| 0f, |
| params.innerRadiusRatio, |
| shapeParams.usesInnerRatio |
| ) |
| MySlider("RoundRadius", 0f, 1f, 0f, params.roundness, shapeParams.usesRoundness) |
| MySlider("Smoothing", 0f, 1f, 0f, params.smooth) |
| MySlider( |
| "InnerRoundRadius", |
| 0f, |
| 1f, |
| 0f, |
| params.innerRoundness, |
| shapeParams.usesInnerParameters |
| ) |
| MySlider("InnerSmoothing", 0f, 1f, 0f, params.innerSmooth, shapeParams.usesInnerParameters) |
| MySlider("Rotation", 0f, 360f, 45f, params.rotation) |
| |
| PanZoomRotateBox( |
| Modifier |
| .clipToBounds() |
| .weight(1f) |
| .border(1.dp, Color.White) |
| .padding(2.dp) |
| ) { |
| PolygonComposableImpl(params.genShape(autoSize = autoSize).also { poly -> |
| if (autoSize) { |
| val matrix = calculateMatrix(poly.bounds, 1f, 1f) |
| poly.transform(matrix) |
| } |
| }, debug = debug) |
| } |
| Row { |
| MyTextButton( |
| onClick = onClose, |
| text = "Accept" |
| ) |
| // TODO: add cancel!? |
| Spacer(Modifier.weight(1f)) |
| MyTextButton( |
| onClick = { debug = !debug }, |
| text = if (debug) "Beziers" else "Shape" |
| ) |
| Spacer(Modifier.weight(1f)) |
| MyTextButton( |
| onClick = { autoSize = !autoSize }, |
| text = if (autoSize) "AutoSize" else "NoSizing" |
| ) |
| Spacer(Modifier.weight(1f)) |
| MyTextButton( |
| onClick = { params.selectedShape().value.debugDump() }, |
| text = "Dump to Logcat" |
| ) |
| } |
| } |
| } |
| |
| @Composable |
| fun MyTextButton( |
| onClick: () -> Unit, |
| text: String, |
| modifier: Modifier = Modifier, |
| // Material defaults are 16 & 8 |
| contentPadding: PaddingValues = PaddingValues(horizontal = 10.dp, vertical = 5.dp), |
| ) = Button(onClick = onClick, modifier = modifier, contentPadding = contentPadding) { |
| Text(text) |
| } |
| |
| @Composable |
| fun MySlider( |
| name: String, |
| minValue: Float, |
| maxValue: Float, |
| step: Float, |
| valueHolder: MutableState<Float>, |
| enabled: Boolean = true |
| ) { |
| Row(Modifier.fillMaxWidth().height(40.dp), verticalAlignment = Alignment.CenterVertically) { |
| Text(name, color = Color.White) |
| Spacer(Modifier.width(10.dp)) |
| Slider( |
| value = valueHolder.value, |
| onValueChange = { valueHolder.value = it }, |
| valueRange = minValue..maxValue, |
| steps = if (step > maxValue - minValue) |
| ((maxValue - minValue) / step).roundToInt() - 1 |
| else |
| 0, |
| enabled = enabled |
| ) |
| } |
| } |
| |
| // TODO: remove this when it is integrated into Ktx |
| operator fun PointF.times(factor: Float): PointF { |
| return PointF(this.x * factor, this.y * factor) |
| } |
| |
| // Create a new list every time, because mutability is complicated. |
| private fun SquarePoints() = listOf( |
| PointF(1f, 1f), |
| PointF(-1f, 1f), |
| PointF(-1f, -1f), |
| PointF(1f, -1f) |
| ) |
| |
| internal fun directionVectorPointF(angleRadians: Float) = |
| PointF(cos(angleRadians), sin(angleRadians)) |