1 /*
2  * Copyright 2022 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
18 
19 import androidx.annotation.FloatRange
20 import androidx.annotation.IntRange
21 import kotlin.jvm.JvmOverloads
22 import kotlin.math.cos
23 import kotlin.math.min
24 
25 /**
26  * Creates a circular shape, approximating the rounding of the shape around the underlying polygon
27  * vertices.
28  *
29  * @param numVertices The number of vertices in the underlying polygon with which to approximate the
30  *   circle, default value is 8
31  * @param radius optional radius for the circle, default value is 1.0
32  * @param centerX X coordinate of optional center for the circle, default value is 0
33  * @param centerY Y coordinate of optional center for the circle, default value is 0
34  * @throws IllegalArgumentException [numVertices] must be at least 3
35  */
36 @JvmOverloads
RoundedPolygonnull37 fun RoundedPolygon.Companion.circle(
38     @IntRange(from = 3) numVertices: Int = 8,
39     radius: Float = 1f,
40     centerX: Float = 0f,
41     centerY: Float = 0f
42 ): RoundedPolygon {
43 
44     if (numVertices < 3) throw IllegalArgumentException("Circle must have at least three vertices")
45 
46     // Half of the angle between two adjacent vertices on the polygon
47     val theta = FloatPi / numVertices
48     // Radius of the underlying RoundedPolygon object given the desired radius of the circle
49     val polygonRadius = radius / cos(theta)
50     return RoundedPolygon(
51         numVertices,
52         rounding = CornerRounding(radius),
53         radius = polygonRadius,
54         centerX = centerX,
55         centerY = centerY
56     )
57 }
58 
59 /**
60  * Creates a rectangular shape with the given width/height around the given center. Optional
61  * rounding parameters can be used to create a rounded rectangle instead.
62  *
63  * As with all [RoundedPolygon] objects, if this shape is created with default dimensions and
64  * center, it is sized to fit within the 2x2 bounding box around a center of (0, 0) and will need to
65  * be scaled and moved using [RoundedPolygon.transformed] to fit the intended area in a UI.
66  *
67  * @param width The width of the rectangle, default value is 2
68  * @param height The height of the rectangle, default value is 2
69  * @param rounding The [CornerRounding] properties of every vertex. If some vertices should have
70  *   different rounding properties, then use [perVertexRounding] instead. The default rounding value
71  *   is [CornerRounding.Unrounded], meaning that the polygon will use the vertices themselves in the
72  *   final shape and not curves rounded around the vertices.
73  * @param perVertexRounding The [CornerRounding] properties of every vertex. If this parameter is
74  *   not null, then it must be of size 4 for the four corners of the shape. If this parameter is
75  *   null, then the polygon will use the [rounding] parameter for every vertex instead. The default
76  *   value is null.
77  * @param centerX The X coordinate of the center of the rectangle, around which all vertices will be
78  *   placed equidistantly. The default center is at (0,0).
79  * @param centerY The X coordinate of the center of the rectangle, around which all vertices will be
80  *   placed equidistantly. The default center is at (0,0).
81  */
RoundedPolygonnull82 fun RoundedPolygon.Companion.rectangle(
83     width: Float = 2f,
84     height: Float = 2f,
85     rounding: CornerRounding = CornerRounding.Unrounded,
86     perVertexRounding: List<CornerRounding>? = null,
87     centerX: Float = 0f,
88     centerY: Float = 0f
89 ): RoundedPolygon {
90     val left = centerX - width / 2
91     val top = centerY - height / 2
92     val right = centerX + width / 2
93     val bottom = centerY + height / 2
94 
95     return RoundedPolygon(
96         floatArrayOf(right, bottom, left, bottom, left, top, right, top),
97         rounding,
98         perVertexRounding,
99         centerX,
100         centerY
101     )
102 }
103 
104 /**
105  * Creates a star polygon, which is like a regular polygon except every other vertex is on either an
106  * inner or outer radius. The two radii specified in the constructor must both both nonzero. If the
107  * radii are equal, the result will be a regular (not star) polygon with twice the number of
108  * vertices specified in [numVerticesPerRadius].
109  *
110  * @param numVerticesPerRadius The number of vertices along each of the two radii.
111  * @param radius Outer radius for this star shape, must be greater than 0. Default value is 1.
112  * @param innerRadius Inner radius for this star shape, must be greater than 0 and less than or
113  *   equal to [radius]. Note that equal radii would be the same as creating a [RoundedPolygon]
114  *   directly, but with 2 * [numVerticesPerRadius] vertices. Default value is .5.
115  * @param rounding The [CornerRounding] properties of every vertex. If some vertices should have
116  *   different rounding properties, then use [perVertexRounding] instead. The default rounding value
117  *   is [CornerRounding.Unrounded], meaning that the polygon will use the vertices themselves in the
118  *   final shape and not curves rounded around the vertices.
119  * @param innerRounding Optional rounding parameters for the vertices on the [innerRadius]. If null
120  *   (the default value), inner vertices will use the [rounding] or [perVertexRounding] parameters
121  *   instead.
122  * @param perVertexRounding The [CornerRounding] properties of every vertex. If this parameter is
123  *   not null, then it must have the same size as 2 * [numVerticesPerRadius]. If this parameter is
124  *   null, then the polygon will use the [rounding] parameter for every vertex instead. The default
125  *   value is null.
126  * @param centerX The X coordinate of the center of the polygon, around which all vertices will be
127  *   placed. The default center is at (0,0).
128  * @param centerY The Y coordinate of the center of the polygon, around which all vertices will be
129  *   placed. The default center is at (0,0).
130  * @throws IllegalArgumentException if either [radius] or [innerRadius] are <= 0 or
131  *   [innerRadius] > [radius].
132  */
133 @JvmOverloads
RoundedPolygonnull134 fun RoundedPolygon.Companion.star(
135     numVerticesPerRadius: Int,
136     radius: Float = 1f,
137     innerRadius: Float = .5f,
138     rounding: CornerRounding = CornerRounding.Unrounded,
139     innerRounding: CornerRounding? = null,
140     perVertexRounding: List<CornerRounding>? = null,
141     centerX: Float = 0f,
142     centerY: Float = 0f
143 ): RoundedPolygon {
144     if (radius <= 0f || innerRadius <= 0f) {
145         throw IllegalArgumentException("Star radii must both be greater than 0")
146     }
147     if (innerRadius >= radius) {
148         throw IllegalArgumentException("innerRadius must be less than radius")
149     }
150 
151     var pvRounding = perVertexRounding
152     // If no per-vertex rounding supplied and caller asked for inner rounding,
153     // create per-vertex rounding list based on supplied outer/inner rounding parameters
154     if (pvRounding == null && innerRounding != null) {
155         pvRounding = (0 until numVerticesPerRadius).flatMap { listOf(rounding, innerRounding) }
156     }
157 
158     // Star polygon is just a polygon with all vertices supplied (where we generate
159     // those vertices to be on the inner/outer radii)
160     return RoundedPolygon(
161         starVerticesFromNumVerts(numVerticesPerRadius, radius, innerRadius, centerX, centerY),
162         rounding,
163         pvRounding,
164         centerX,
165         centerY
166     )
167 }
168 
169 /**
170  * A pill shape consists of a rectangle shape bounded by two semicircles at either of the long ends
171  * of the rectangle.
172  *
173  * @param width The width of the resulting shape.
174  * @param height The height of the resulting shape.
175  * @param smoothing the amount by which the arc is "smoothed" by extending the curve from the
176  *   circular arc on each endcap to the edge between the endcaps. A value of 0 (no smoothing)
177  *   indicates that the corner is rounded by only a circular arc.
178  * @param centerX The X coordinate of the center of the polygon, around which all vertices will be
179  *   placed. The default center is at (0,0).
180  * @param centerY The Y coordinate of the center of the polygon, around which all vertices will be
181  *   placed. The default center is at (0,0).
182  * @throws IllegalArgumentException if either [width] or [height] are <= 0.
183  */
184 @JvmOverloads
RoundedPolygonnull185 fun RoundedPolygon.Companion.pill(
186     width: Float = 2f,
187     height: Float = 1f,
188     smoothing: Float = 0f,
189     centerX: Float = 0f,
190     centerY: Float = 0f,
191 ): RoundedPolygon {
192     require(width > 0f && height > 0f) {
193         throw IllegalArgumentException("Pill shapes must have positive width and height")
194     }
195 
196     val wHalf = width / 2
197     val hHalf = height / 2
198     return RoundedPolygon(
199         vertices =
200             floatArrayOf(
201                 wHalf + centerX,
202                 hHalf + centerY,
203                 -wHalf + centerX,
204                 hHalf + centerY,
205                 -wHalf + centerX,
206                 -hHalf + centerY,
207                 wHalf + centerX,
208                 -hHalf + centerY,
209             ),
210         rounding = CornerRounding(min(wHalf, hHalf), smoothing),
211         centerX = centerX,
212         centerY = centerY
213     )
214 }
215 
216 /**
217  * A pillStar shape is like a [pill] except it has inner and outer radii along its pill-shaped
218  * outline, just like a [star] has inner and outer radii along its circular outline. The parameters
219  * for a [pillStar] are similar to those of a [star] except, like [pill], it has a [width] and
220  * [height] to determine the general shape of the underlying pill. Also, there is a subtle
221  * complication with the way that inner and outer vertices proceed along the circular ends of the
222  * shape, depending on the magnitudes of the [rounding], [innerRounding], and [innerRadiusRatio]
223  * parameters. For example, a shape with outer vertices that lie along the curved end outline will
224  * necessarily have inner vertices that are closer to each other, because of the curvature of that
225  * part of the shape. Conversely, if the inner vertices are lined up along the pill outline at the
226  * ends, then the outer vertices will be much further apart from each other.
227  *
228  * The default approach, reflected by the default value of [vertexSpacing], is to use the average of
229  * the outer and inner radii, such that each set of vertices falls equally to the other side of the
230  * pill outline on the curved ends. Depending on the values used for the various rounding and radius
231  * parameters, you may want to change that value to suit the look you want. A value of 0 for
232  * [vertexSpacing] is equivalent to aligning the inner vertices along the circular curve, and a
233  * value of 1 is equivalent to aligning the outer vertices along that curve.
234  *
235  * @param width The width of the resulting shape.
236  * @param height The height of the resulting shape.
237  * @param numVerticesPerRadius The number of vertices along each of the two radii.
238  * @param innerRadiusRatio Inner radius ratio for this star shape, must be greater than 0 and less
239  *   than or equal to 1. Note that a value of 1 would be similar to creating a [pill], but with more
240  *   vertices. The default value is .5.
241  * @param rounding The [CornerRounding] properties of every vertex. If some vertices should have
242  *   different rounding properties, then use [perVertexRounding] instead. The default rounding value
243  *   is [CornerRounding.Unrounded], meaning that the polygon will use the vertices themselves in the
244  *   final shape and not curves rounded around the vertices.
245  * @param innerRounding Optional rounding parameters for the vertices on the [innerRadiusRatio]. If
246  *   null (the default value), inner vertices will use the [rounding] or [perVertexRounding]
247  *   parameters instead.
248  * @param perVertexRounding The [CornerRounding] properties of every vertex. If this parameter is
249  *   not null, then it must have the same size as 2 * [numVerticesPerRadius]. If this parameter is
250  *   null, then the polygon will use the [rounding] parameter for every vertex instead. The default
251  *   value is null.
252  * @param vertexSpacing This factor determines how the vertices on the circular ends are laid out
253  *   along the outline. A value of 0 aligns spaces the inner vertices the same as those along the
254  *   straight edges, with the outer vertices then being spaced further apart. A value of 1 does the
255  *   opposite, with the outer vertices spaced the same as the vertices on the straight edges. The
256  *   default value is .5, which takes the average of these two extremes.
257  * @param startLocation A value from 0 to 1 which determines how far along the perimeter of this
258  *   shape to start the underlying curves of which it is comprised. This is not usually needed or
259  *   noticed by the user. But if the caller wants to manually and gradually stroke the path when
260  *   drawing it, it might matter where that path outline begins and ends. The default value is 0.
261  * @param centerX The X coordinate of the center of the polygon, around which all vertices will be
262  *   placed. The default center is at (0,0).
263  * @param centerY The Y coordinate of the center of the polygon, around which all vertices will be
264  *   placed. The default center is at (0,0).
265  * @throws IllegalArgumentException if either [width] or [height] are <= 0 or if [innerRadiusRatio]
266  *   is outside the range of (0, 1].
267  */
268 @JvmOverloads
RoundedPolygonnull269 fun RoundedPolygon.Companion.pillStar(
270     width: Float = 2f,
271     height: Float = 1f,
272     numVerticesPerRadius: Int = 8,
273     @FloatRange(from = 0.0, fromInclusive = false, to = 1.0, toInclusive = false)
274     innerRadiusRatio: Float = .5f,
275     rounding: CornerRounding = CornerRounding.Unrounded,
276     innerRounding: CornerRounding? = null,
277     perVertexRounding: List<CornerRounding>? = null,
278     @FloatRange(from = 0.0, to = 1.0) vertexSpacing: Float = 0.5f,
279     @FloatRange(from = 0.0, to = 1.0) startLocation: Float = 0f,
280     centerX: Float = 0f,
281     centerY: Float = 0f
282 ): RoundedPolygon {
283     require(width > 0f && height > 0f) {
284         throw IllegalArgumentException("Pill shapes must have positive width and height")
285     }
286     require(innerRadiusRatio > 0 && innerRadiusRatio <= 1) {
287         throw IllegalArgumentException("innerRadius must be between 0 and 1")
288     }
289 
290     var pvRounding = perVertexRounding
291     // If no per-vertex rounding supplied and caller asked for inner rounding,
292     // create per-vertex rounding list based on supplied outer/inner rounding parameters
293     if (pvRounding == null && innerRounding != null) {
294         pvRounding = (0 until numVerticesPerRadius).flatMap { listOf(rounding, innerRounding) }
295     }
296     return RoundedPolygon(
297         pillStarVerticesFromNumVerts(
298             numVerticesPerRadius,
299             width,
300             height,
301             innerRadiusRatio,
302             vertexSpacing,
303             startLocation,
304             centerX,
305             centerY
306         ),
307         rounding,
308         pvRounding,
309         centerX,
310         centerY
311     )
312 }
313 
pillStarVerticesFromNumVertsnull314 private fun pillStarVerticesFromNumVerts(
315     numVerticesPerRadius: Int,
316     width: Float,
317     height: Float,
318     innerRadius: Float,
319     vertexSpacing: Float,
320     startLocation: Float,
321     centerX: Float,
322     centerY: Float
323 ): FloatArray {
324     // The general approach here is to get the perimeter of the underlying pill outline,
325     // then the t value for each vertex as we walk that perimeter. This tells us where
326     // on the outline to place that vertex, then we figure out where to place the vertex
327     // depending on which "section" it is in. The possible sections are the vertical edges
328     // on the sides, the circular sections on all four corners, or the horizontal edges
329     // on the top and bottom. Note that either the vertical or horizontal edges will be
330     // of length zero (whichever dimension is smaller gets only circular curvature for the
331     // pill shape).
332     val endcapRadius = min(width, height)
333     val vSegLen = (height - width).coerceAtLeast(0f)
334     val hSegLen = (width - height).coerceAtLeast(0f)
335     val vSegHalf = vSegLen / 2
336     val hSegHalf = hSegLen / 2
337     // vertexSpacing is used to position the vertices on the end caps. The caller has the choice
338     // of spacing the inner (0) or outer (1) vertices like those along the edges, causing the
339     // other vertices to be either further apart (0) or closer (1). The default is .5, which
340     // averages things. The magnitude of the inner and rounding parameters may cause the caller
341     // to want a different value.
342     val circlePerimeter = TwoPi * endcapRadius * interpolate(innerRadius, 1f, vertexSpacing)
343     // perimeter is circle perimeter plus horizontal and vertical sections of inner rectangle,
344     // whether either (or even both) might be of length zero.
345     val perimeter = 2 * hSegLen + 2 * vSegLen + circlePerimeter
346 
347     // The sections array holds the t start values of that part of the outline. We use these to
348     // determine which section a given vertex lies in, based on its t value, as well as where
349     // in that section it lies.
350     val sections = FloatArray(11)
351     sections[0] = 0f
352     sections[1] = vSegLen / 2
353     sections[2] = sections[1] + circlePerimeter / 4
354     sections[3] = sections[2] + hSegLen
355     sections[4] = sections[3] + circlePerimeter / 4
356     sections[5] = sections[4] + vSegLen
357     sections[6] = sections[5] + circlePerimeter / 4
358     sections[7] = sections[6] + hSegLen
359     sections[8] = sections[7] + circlePerimeter / 4
360     sections[9] = sections[8] + vSegLen / 2
361     sections[10] = perimeter
362 
363     // "t" is the length along the entire pill outline for a given vertex. With vertices spaced
364     // evenly along this contour, we can determine for any vertex where it should lie.
365     val tPerVertex = perimeter / (2 * numVerticesPerRadius)
366     // separate iteration for inner vs outer, unlike the other shapes, because
367     // the vertices can lie in different quadrants so each needs their own calculation
368     var inner = false
369     // Increment section index as we walk around the pill contour with our increasing t values
370     var currSecIndex = 0
371     // secStart/End are used to determine how far along a given vertex is in the section
372     // in which it lands
373     var secStart = 0f
374     var secEnd = sections[1]
375     // t value is used to place each vertex. 0 is on the positive x axis,
376     // moving into section 0 to begin with. startLocation, a value from 0 to 1, varies the location
377     // anywhere on the perimeter of the shape
378     var t = startLocation * perimeter
379     // The list of vertices to be returned
380     val result = FloatArray(numVerticesPerRadius * 4)
381     var arrayIndex = 0
382     val rectBR = Point(hSegHalf, vSegHalf)
383     val rectBL = Point(-hSegHalf, vSegHalf)
384     val rectTL = Point(-hSegHalf, -vSegHalf)
385     val rectTR = Point(hSegHalf, -vSegHalf)
386     // Each iteration through this loop uses the next t value as we walk around the shape
387     for (i in 0 until numVerticesPerRadius * 2) {
388 
389         // t could start (and end) after 0; extra boundedT logic makes sure it does the right
390         // thing when crossing the boundar past 0 again
391         val boundedT = t % perimeter
392         if (boundedT < secStart) currSecIndex = 0
393         while (boundedT >= sections[(currSecIndex + 1) % sections.size]) {
394             currSecIndex = (currSecIndex + 1) % sections.size
395             secStart = sections[currSecIndex]
396             secEnd = sections[(currSecIndex + 1) % sections.size]
397         }
398 
399         // find t in section and its proportion of that section's total length
400         val tInSection = boundedT - secStart
401         val tProportion = tInSection / (secEnd - secStart)
402 
403         // The vertex placement in a section varies depending on whether it is on one of the
404         // semicircle endcaps or along one of the straight edges. For the endcaps, we use
405         // tProportion to get the angle along that circular cap and add
406         // the starting angle for that section. For the edges we use a straight linear calculation
407         // given tProportion and the start/end t values for that edge.
408         val currRadius = if (inner) (endcapRadius * innerRadius) else endcapRadius
409         val vertex: Point =
410             when (currSecIndex) {
411                 0 -> Point(currRadius, tProportion * vSegHalf)
412                 1 -> radialToCartesian(radius = currRadius, tProportion * FloatPi / 2) + rectBR
413                 2 -> Point(hSegHalf - tProportion * hSegLen, currRadius)
414                 3 ->
415                     radialToCartesian(
416                         radius = currRadius,
417                         FloatPi / 2 + (tProportion * FloatPi / 2)
418                     ) + rectBL
419                 4 -> Point(-currRadius, vSegHalf - tProportion * vSegLen)
420                 5 ->
421                     radialToCartesian(radius = currRadius, FloatPi + (tProportion * FloatPi / 2)) +
422                         rectTL
423                 6 -> Point(-hSegHalf + tProportion * hSegLen, -currRadius)
424                 7 ->
425                     radialToCartesian(
426                         radius = currRadius,
427                         FloatPi * 1.5f + (tProportion * FloatPi / 2)
428                     ) + rectTR
429                 // 8
430                 else -> Point(currRadius, -vSegHalf + tProportion * vSegHalf)
431             }
432         result[arrayIndex++] = vertex.x + centerX
433         result[arrayIndex++] = vertex.y + centerY
434         t += tPerVertex
435         inner = !inner
436     }
437     return result
438 }
439 
starVerticesFromNumVertsnull440 private fun starVerticesFromNumVerts(
441     numVerticesPerRadius: Int,
442     radius: Float,
443     innerRadius: Float,
444     centerX: Float,
445     centerY: Float
446 ): FloatArray {
447     val result = FloatArray(numVerticesPerRadius * 4)
448     var arrayIndex = 0
449     for (i in 0 until numVerticesPerRadius) {
450         var vertex = radialToCartesian(radius, (FloatPi / numVerticesPerRadius * 2 * i))
451         result[arrayIndex++] = vertex.x + centerX
452         result[arrayIndex++] = vertex.y + centerY
453         vertex = radialToCartesian(innerRadius, (FloatPi / numVerticesPerRadius * (2 * i + 1)))
454         result[arrayIndex++] = vertex.x + centerX
455         result[arrayIndex++] = vertex.y + centerY
456     }
457     return result
458 }
459