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