1 /*
<lambda>null2 * Copyright 2023 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package androidx.graphics.shapes.testcompose
18
19 import androidx.compose.runtime.derivedStateOf
20 import androidx.compose.runtime.getValue
21 import androidx.compose.runtime.mutableFloatStateOf
22 import androidx.compose.runtime.mutableIntStateOf
23 import androidx.compose.runtime.mutableStateOf
24 import androidx.compose.runtime.setValue
25 import androidx.compose.ui.geometry.Offset
26 import androidx.compose.ui.graphics.Matrix
27 import androidx.graphics.shapes.CornerRounding
28 import androidx.graphics.shapes.Feature
29 import androidx.graphics.shapes.FeatureSerializer
30 import androidx.graphics.shapes.RoundedPolygon
31 import androidx.graphics.shapes.circle
32 import androidx.graphics.shapes.pill
33 import androidx.graphics.shapes.pillStar
34 import androidx.graphics.shapes.rectangle
35 import androidx.graphics.shapes.star
36 import kotlin.math.min
37 import kotlin.math.roundToInt
38
39 data class ShapeItem(
40 val name: String,
41 var shapegen: () -> RoundedPolygon,
42 val shapeOutput: () -> String,
43 val usesSides: Boolean = true,
44 val usesWidthAndHeight: Boolean = false,
45 val usesPillStarFactor: Boolean = false,
46 val usesInnerRatio: Boolean = true,
47 val usesRoundness: Boolean = true,
48 val usesInnerParameters: Boolean = true
49 )
50
51 open class ShapeParameters(
52 val name: String,
53 sides: Int = 5,
54 innerRadius: Float = 0.5f,
55 roundness: Float = 0f,
56 smooth: Float = 0f,
57 innerRoundness: Float = roundness,
58 innerSmooth: Float = smooth,
59 rotation: Float = 0f,
60 width: Float = 1f,
61 height: Float = 1f,
62 pillStarFactor: Float = .5f,
63 shapeId: ShapeId = ShapeId.Polygon,
64 splitProgress: Float = 1.0f,
65 customFeaturesOverlay: List<FeatureType> = listOf()
66 ) {
67 internal val sides = mutableFloatStateOf(sides.toFloat())
68 internal val innerRadius = mutableFloatStateOf(innerRadius)
69 internal val roundness = mutableFloatStateOf(roundness)
70 internal val smooth = mutableFloatStateOf(smooth)
71 internal val innerRoundness = mutableFloatStateOf(innerRoundness)
72 internal val innerSmooth = mutableFloatStateOf(innerSmooth)
73 internal val rotation = mutableFloatStateOf(rotation)
74 internal val width = mutableFloatStateOf(width)
75 internal val height = mutableFloatStateOf(height)
76 internal val pillStarFactor = mutableFloatStateOf(pillStarFactor)
77 internal val splitProgress = mutableFloatStateOf(splitProgress)
78 internal val customFeaturesOverlay = mutableStateOf(customFeaturesOverlay)
79
80 internal var shapeIx by mutableIntStateOf(shapeId.ordinal)
81
82 open val isCustom: Boolean = false
83
84 // Primitive shapes we can draw (so far)
85 internal val shapes =
86 listOf(
87 ShapeItem(
88 "Pill",
89 shapegen = {
90 RoundedPolygon.pill(
91 width = this.width.floatValue,
92 height = this.height.floatValue,
93 smoothing = this.smooth.floatValue
94 )
95 },
96 shapeOutput = {
97 shapeDescription(
98 id = "Pill",
99 width = this.width.floatValue,
100 height = this.height.floatValue,
101 code =
102 "RoundedPolygon.pill(width = $this.width.floatValue, " +
103 "height = $this.height.floatValue)"
104 )
105 },
106 usesSides = false,
107 usesInnerParameters = false,
108 usesInnerRatio = false,
109 usesRoundness = true,
110 usesWidthAndHeight = true
111 ),
112 ShapeItem(
113 "PillStar",
114 shapegen = {
115 RoundedPolygon.pillStar(
116 width = this.width.floatValue,
117 height = this.height.floatValue,
118 numVerticesPerRadius = this.sides.floatValue.roundToInt(),
119 innerRadiusRatio = this.innerRadius.floatValue,
120 rounding =
121 CornerRounding(this.roundness.floatValue, this.smooth.floatValue),
122 innerRounding =
123 CornerRounding(
124 this.innerRoundness.floatValue,
125 this.innerSmooth.floatValue
126 ),
127 vertexSpacing = this.pillStarFactor.floatValue
128 )
129 },
130 shapeOutput = {
131 shapeDescription(
132 id = "PillStar",
133 width = this.width.floatValue,
134 height = this.height.floatValue,
135 numVerts = this.sides.floatValue.roundToInt(),
136 innerRadius = this.innerRadius.floatValue,
137 roundness = this.roundness.floatValue,
138 smooth = this.smooth.floatValue,
139 innerRoundness = this.innerRoundness.floatValue,
140 innerSmooth = this.innerSmooth.floatValue,
141 rotation = this.rotation.floatValue,
142 code =
143 "RoundedPolygon.pillStar(width = $width, height = $height," +
144 "numVerticesPerRadius = $sides, " +
145 "innerRadius = ${innerRadius}f, " +
146 "rounding = CornerRounding(${roundness}f, ${smooth}f), " +
147 "innerRounding = CornerRounding(${innerRoundness}f, ${innerSmooth}f))"
148 )
149 },
150 usesWidthAndHeight = true,
151 usesPillStarFactor = true
152 ),
153 ShapeItem(
154 "Star",
155 shapegen = {
156 RoundedPolygon.star(
157 radius = 1f,
158 numVerticesPerRadius = this.sides.floatValue.roundToInt(),
159 innerRadius = this.innerRadius.floatValue,
160 rounding =
161 CornerRounding(this.roundness.floatValue, this.smooth.floatValue),
162 innerRounding =
163 CornerRounding(
164 this.innerRoundness.floatValue,
165 this.innerSmooth.floatValue
166 )
167 )
168 },
169 shapeOutput = {
170 shapeDescription(
171 id = "Star",
172 sides = this.sides.floatValue.roundToInt(),
173 innerRadius = this.innerRadius.floatValue,
174 roundness = this.roundness.floatValue,
175 smooth = this.smooth.floatValue,
176 innerRoundness = this.innerRoundness.floatValue,
177 innerSmooth = this.innerSmooth.floatValue,
178 rotation = this.rotation.floatValue,
179 code =
180 "RoundedPolygon.star(" +
181 "radius = 2f, " +
182 "numVerticesPerRadius = ${this.sides.floatValue.roundToInt()}, " +
183 "innerRadius = ${this.innerRadius.floatValue}f, " +
184 "rounding = " +
185 "CornerRounding(${this.roundness.floatValue}f," +
186 "${this.smooth.floatValue}f), " +
187 "innerRounding = CornerRounding(${this.innerRoundness.floatValue}f, " +
188 "${this.innerSmooth.floatValue}f))"
189 )
190 },
191 ),
192 ShapeItem(
193 "Polygon",
194 shapegen = {
195 RoundedPolygon(
196 numVertices = this.sides.floatValue.roundToInt(),
197 rounding =
198 CornerRounding(this.roundness.floatValue, this.smooth.floatValue),
199 )
200 },
201 shapeOutput = {
202 shapeDescription(
203 id = "Polygon",
204 sides = this.sides.floatValue.roundToInt(),
205 roundness = this.roundness.floatValue,
206 smooth = this.smooth.floatValue,
207 rotation = this.rotation.floatValue,
208 code =
209 "RoundedPolygon(numVertices = ${this.sides.floatValue.roundToInt()}," +
210 "rounding = CornerRounding(${this.roundness.floatValue}f, " +
211 "${this.smooth.floatValue}f))"
212 )
213 },
214 usesInnerRatio = false,
215 usesInnerParameters = false
216 ),
217 ShapeItem(
218 "Triangle",
219 shapegen = {
220 val points =
221 floatArrayOf(
222 radialToCartesian(1f, 270f.toRadians()).x,
223 radialToCartesian(1f, 270f.toRadians()).y,
224 radialToCartesian(1f, 30f.toRadians()).x,
225 radialToCartesian(1f, 30f.toRadians()).y,
226 radialToCartesian(this.innerRadius.floatValue, 90f.toRadians()).x,
227 radialToCartesian(this.innerRadius.floatValue, 90f.toRadians()).y,
228 radialToCartesian(1f, 150f.toRadians()).x,
229 radialToCartesian(1f, 150f.toRadians()).y
230 )
231 RoundedPolygon(
232 points,
233 CornerRounding(this.roundness.floatValue, this.smooth.floatValue),
234 centerX = 0f,
235 centerY = 0f
236 )
237 },
238 shapeOutput = {
239 shapeDescription(
240 id = "Triangle",
241 innerRadius = this.innerRadius.floatValue,
242 smooth = this.smooth.floatValue,
243 rotation = this.rotation.floatValue,
244 code =
245 "val points = floatArrayOf(" +
246 " radialToCartesian(1f, 270f.toRadians()).x,\n" +
247 " radialToCartesian(1f, 270f.toRadians()).y,\n" +
248 " radialToCartesian(1f, 30f.toRadians()).x,\n" +
249 " radialToCartesian(1f, 30f.toRadians()).y,\n" +
250 " radialToCartesian(${this.innerRadius.floatValue}f, " +
251 "90f.toRadians()).x,\n" +
252 " radialToCartesian(${this.innerRadius.floatValue}f, " +
253 "90f.toRadians()).y,\n" +
254 " radialToCartesian(1f, 150f.toRadians()).x,\n" +
255 " radialToCartesian(1f, 150f.toRadians()).y)\n" +
256 "RoundedPolygon(points, CornerRounding(" +
257 "${this.roundness.floatValue}f, ${this.smooth.floatValue}f), " +
258 "centerX = 0f, centerY = 0f)"
259 )
260 },
261 usesSides = false,
262 usesInnerParameters = false
263 ),
264 ShapeItem(
265 "Blob",
266 shapegen = {
267 val sx = this.innerRadius.floatValue.coerceAtLeast(0.1f)
268 val sy = this.roundness.floatValue.coerceAtLeast(0.1f)
269 RoundedPolygon(
270 vertices =
271 floatArrayOf(
272 -sx,
273 -sy,
274 sx,
275 -sy,
276 sx,
277 sy,
278 -sx,
279 sy,
280 ),
281 rounding = CornerRounding(min(sx, sy), this.smooth.floatValue),
282 centerX = 0f,
283 centerY = 0f
284 )
285 },
286 shapeOutput = {
287 shapeDescription(
288 id = "Blob",
289 innerRadius = this.innerRadius.floatValue,
290 roundness = this.roundness.floatValue,
291 smooth = this.smooth.floatValue,
292 rotation = this.rotation.floatValue,
293 code =
294 "val sx = ${this.innerRadius.floatValue}f.coerceAtLeast(0.1f)\n" +
295 "val sy = ${this.roundness.floatValue}f.coerceAtLeast(.1f)\n" +
296 "val verts = floatArrayOf(-sx, -sy, sx, -sy, sx, sy, -sx, sy)\n" +
297 "RoundedPolygon(verts, rounding = CornerRounding(min(sx, sy), " +
298 "${this.smooth.floatValue}f)," +
299 "centerX = 0f, centerY = 0f)"
300 )
301 },
302 usesSides = false,
303 usesInnerParameters = false
304 ),
305 ShapeItem(
306 "CornerSE",
307 shapegen = {
308 RoundedPolygon(
309 squarePoints(),
310 perVertexRounding =
311 listOf(
312 CornerRounding(this.roundness.floatValue, this.smooth.floatValue),
313 CornerRounding(1f),
314 CornerRounding(1f),
315 CornerRounding(1f)
316 ),
317 centerX = 0f,
318 centerY = 0f
319 )
320 },
321 shapeOutput = {
322 shapeDescription(
323 id = "cornerSE",
324 roundness = this.roundness.floatValue,
325 smooth = this.smooth.floatValue,
326 rotation = this.rotation.floatValue,
327 code =
328 "RoundedPolygon(floatArrayOf(1f, 1f, -1f, 1f, -1f, -1f, 1f, -1f), " +
329 "perVertexRounding = listOf(CornerRounding(" +
330 "${this.roundness.floatValue}f, ${this.smooth.floatValue}f), " +
331 "CornerRounding(1f), CornerRounding(1f), CornerRounding(1f))," +
332 "centerX = 0f, centerY = 0f)"
333 )
334 },
335 usesSides = false,
336 usesInnerRatio = false,
337 usesInnerParameters = false
338 ),
339 ShapeItem(
340 "Circle",
341 shapegen = {
342 RoundedPolygon.circle(this.sides.floatValue.roundToInt())
343 .transformed(
344 Matrix().apply {
345 scale(
346 x = this@ShapeParameters.width.floatValue,
347 y = this@ShapeParameters.height.floatValue
348 )
349 rotateX(rotation)
350 }
351 )
352 },
353 shapeOutput = {
354 shapeDescription(
355 id = "Circle",
356 roundness = this.roundness.floatValue,
357 smooth = this.smooth.floatValue,
358 rotation = this.rotation.floatValue,
359 code = "RoundedPolygon.circle($sides)"
360 )
361 },
362 usesSides = true,
363 usesInnerRatio = false,
364 usesWidthAndHeight = true,
365 usesInnerParameters = false
366 ),
367 ShapeItem(
368 "Rectangle",
369 shapegen = {
370 RoundedPolygon.rectangle(
371 width = 4f,
372 height = 2f,
373 rounding =
374 CornerRounding(this.roundness.floatValue, this.smooth.floatValue),
375 )
376 },
377 shapeOutput = {
378 shapeDescription(
379 id = "Rectangle",
380 numVerts = 4,
381 roundness = this.roundness.floatValue,
382 smooth = this.smooth.floatValue,
383 rotation = this.rotation.floatValue,
384 code =
385 "RoundedPolygon.rectangle(width = 4f, height = 2f, " +
386 "rounding = CornerRounding(" +
387 "${this.roundness.floatValue}f, ${this.smooth.floatValue}f))"
388 )
389 },
390 usesSides = false,
391 usesInnerRatio = false,
392 usesInnerParameters = false
393 )
394
395 /*
396 TODO: Add quarty. Needs to be able to specify a rounding radius of up to 2f
397 ShapeItem("Quarty", { DefaultShapes.quarty(roundness.value, smooth.value) },
398 usesSides = false, usesInnerRatio = false),
399 */
400 )
401
402 open fun copy() =
403 ShapeParameters(
404 this.name,
405 this.sides.floatValue.roundToInt(),
406 this.innerRadius.floatValue,
407 this.roundness.floatValue,
408 this.smooth.floatValue,
409 this.innerRoundness.floatValue,
410 this.innerSmooth.floatValue,
411 this.rotation.floatValue,
412 this.width.floatValue,
413 this.height.floatValue,
414 this.pillStarFactor.floatValue,
415 ShapeId.values()[this.shapeIx],
416 this.splitProgress.floatValue,
417 this.customFeaturesOverlay.value.map { it }
418 )
419
420 enum class ShapeId {
421 Pill,
422 PillStar,
423 Star,
424 Polygon,
425 Triangle,
426 Blob,
427 CornerSE,
428 Circle,
429 Rectangle
430 }
431
432 fun serialized(): String =
433 """
434 val features: List<Feature> = FeatureSerializer.parse("${FeatureSerializer.serialize(genShape().features)}")
435 val shape: RoundedPolygon = RoundedPolygon(features, centerX = <recommended to add>, centerY = <recommended to add>)
436 """
437 .trimIndent()
438
439 private fun shapeDescription(
440 id: String? = null,
441 numVerts: Int? = null,
442 sides: Int? = null,
443 innerRadius: Float? = null,
444 roundness: Float? = null,
445 innerRoundness: Float? = null,
446 smooth: Float? = null,
447 innerSmooth: Float? = null,
448 rotation: Float? = null,
449 width: Float? = null,
450 height: Float? = null,
451 pillStarFactor: Float? = null,
452 splitProgress: Float? = null,
453 customFeaturesOverlay: List<FeatureType>? = null,
454 code: String? = null,
455 ): String {
456 var description = "ShapeParameters:\n"
457 if (id != null) description += "shapeId = $id, "
458 if (numVerts != null) description += "numVertices = $numVerts, "
459 if (sides != null) description += "sides = $sides, "
460 if (innerRadius != null) description += "innerRadius = $innerRadius, "
461 if (roundness != null) description += "roundness = $roundness, "
462 if (innerRoundness != null) description += "innerRoundness = $innerRoundness, "
463 if (smooth != null) description += "smoothness = $smooth, "
464 if (innerSmooth != null) description += "innerSmooth = $innerSmooth, "
465 if (rotation != null) description += "rotation = $rotation, "
466 if (width != null) description += "width = $width, "
467 if (height != null) description += "height = $height, "
468 if (pillStarFactor != null) description += "pillStarFactor = $pillStarFactor, "
469 if (splitProgress != null) description += "splitProgress = $splitProgress, "
470 if (customFeaturesOverlay != null) {
471 description +=
472 "customFeaturesOverlay = ${customFeaturesOverlay.joinToString(separator = " ") { it.name }}, "
473 }
474 if (code != null) {
475 description += "\nCode:\n$code"
476 }
477 return description
478 }
479
480 open fun selectedShape() = derivedStateOf { shapes[shapeIx] }
481
482 fun genShape(autoSize: Boolean = true): RoundedPolygon {
483 // TODO: b/378433883 - Improve combined overlay - split interactions
484
485 val original = selectedShape().value.shapegen()
486 val split = original.split(splitProgress.floatValue)
487
488 if (split.features.size != customFeaturesOverlay.value.size) {
489 customFeaturesOverlay.value = split.features.map { it.toFeatureType() }
490 }
491
492 val customizedSplitPolygon =
493 RoundedPolygon(
494 split.features.mapIndexed { index, feature ->
495 customFeaturesOverlay.value[index].apply(feature)
496 }
497 )
498
499 return customizedSplitPolygon.transformed(
500 Matrix().apply {
501 if (autoSize) {
502 val bounds = customizedSplitPolygon.getBounds()
503 // Move the center to the origin.
504 translate(
505 x = -(bounds.left + bounds.right) / 2,
506 y = -(bounds.top + bounds.bottom) / 2
507 )
508
509 // Scale to the [-1, 1] range
510 val scale = 2f / bounds.maxDimension
511 scale(x = scale, y = scale)
512 }
513 // Apply the needed rotation
514 rotateZ(rotation.floatValue)
515 }
516 )
517 }
518
519 internal fun equals(other: ShapeParameters) =
520 this.shapeDescription(
521 sides = sides.floatValue.toInt(),
522 innerRadius = innerRadius.floatValue,
523 roundness = roundness.floatValue,
524 smooth = smooth.floatValue,
525 innerRoundness = innerRoundness.floatValue,
526 innerSmooth = innerSmooth.floatValue,
527 rotation = rotation.floatValue,
528 width = width.floatValue,
529 height = height.floatValue,
530 pillStarFactor = pillStarFactor.floatValue,
531 splitProgress = splitProgress.floatValue,
532 customFeaturesOverlay = customFeaturesOverlay.value
533 ) ==
534 other.shapeDescription(
535 sides = other.sides.floatValue.toInt(),
536 innerRadius = other.innerRadius.floatValue,
537 roundness = other.roundness.floatValue,
538 smooth = other.smooth.floatValue,
539 innerRoundness = other.innerRoundness.floatValue,
540 innerSmooth = other.innerSmooth.floatValue,
541 rotation = other.rotation.floatValue,
542 width = other.width.floatValue,
543 height = other.height.floatValue,
544 pillStarFactor = other.pillStarFactor.floatValue,
545 splitProgress = other.splitProgress.floatValue,
546 customFeaturesOverlay = other.customFeaturesOverlay.value
547 )
548
549 private fun radialToCartesian(
550 radius: Float,
551 angleRadians: Float,
552 center: Offset = Offset.Zero
553 ) = directionVector(angleRadians) * radius + center
554 }
555
556 class CustomShapeParameters(
557 name: String,
558 sides: Int = 5,
559 innerRadius: Float = 0.5f,
560 roundness: Float = 0f,
561 smooth: Float = 0f,
562 innerRoundness: Float = roundness,
563 innerSmooth: Float = smooth,
564 rotation: Float = 0f,
565 width: Float = 1f,
566 height: Float = 1f,
567 pillStarFactor: Float = .5f,
568 shapeId: ShapeId = ShapeId.Polygon,
569 splitProgress: Float = 1.0f,
570 customFeaturesOverlay: List<FeatureType> = listOf(),
571 private val shapegen: () -> RoundedPolygon
572 ) :
573 ShapeParameters(
574 name,
575 sides,
576 innerRadius,
577 roundness,
578 smooth,
579 innerRoundness,
580 innerSmooth,
581 rotation,
582 width,
583 height,
584 pillStarFactor,
585 shapeId,
586 splitProgress,
587 customFeaturesOverlay
588 ) {
589
590 override val isCustom: Boolean = true
591
<lambda>null592 override fun selectedShape() = derivedStateOf {
593 ShapeItem(name = name, shapegen = shapegen, shapeOutput = { "Custom Shape: $name" })
594 }
595
copynull596 override fun copy(): CustomShapeParameters =
597 CustomShapeParameters(
598 this.name,
599 this.sides.floatValue.roundToInt(),
600 this.innerRadius.floatValue,
601 this.roundness.floatValue,
602 this.smooth.floatValue,
603 this.innerRoundness.floatValue,
604 this.innerSmooth.floatValue,
605 this.rotation.floatValue,
606 this.width.floatValue,
607 this.height.floatValue,
608 this.pillStarFactor.floatValue,
609 ShapeId.values()[this.shapeIx],
610 this.splitProgress.floatValue,
611 this.customFeaturesOverlay.value.map { it },
612 this.shapegen,
613 )
614 }
615
squarePointsnull616 private fun squarePoints() = floatArrayOf(1f, 1f, -1f, 1f, -1f, -1f, 1f, -1f)
617
618 enum class FeatureType {
619 IGNORABLE {
620 override fun apply(feature: Feature) = Feature.buildIgnorableFeature(feature.cubics)
621 },
622 CONVEX_CORNER {
623 override fun apply(feature: Feature) = Feature.buildConvexCorner(feature.cubics)
624 },
625 CONCAVE_CORNER {
626 override fun apply(feature: Feature) = Feature.buildConcaveCorner(feature.cubics)
627 };
628
629 abstract fun apply(feature: Feature): Feature
630 }
631
toFeatureTypenull632 internal fun Feature.toFeatureType(): FeatureType =
633 if (isEdge) {
634 FeatureType.IGNORABLE
635 } else if (isConvexCorner) {
636 FeatureType.CONVEX_CORNER
637 } else if (isConcaveCorner) {
638 FeatureType.CONCAVE_CORNER
639 } else {
640 FeatureType.IGNORABLE
641 }
642
toggleFeatureTypenull643 internal fun toggleFeatureType(typeToToggle: FeatureType): FeatureType {
644 val currentFeatureIndex = FeatureType.values().indexOf(typeToToggle)
645 val nextFeatureType =
646 FeatureType.values()[(currentFeatureIndex + 1) % FeatureType.values().size]
647 return nextFeatureType
648 }
649