1 /* <lambda>null2 * 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 kotlin.jvm.JvmOverloads 20 import kotlin.jvm.JvmStatic 21 import kotlin.math.max 22 import kotlin.math.min 23 24 /** 25 * This class is used to animate between start and end polygons objects. 26 * 27 * Morphing between arbitrary objects can be problematic because it can be difficult to determine 28 * how the points of a given shape map to the points of some other shape. [Morph] simplifies the 29 * problem by only operating on [RoundedPolygon] objects, which are known to have similar, 30 * contiguous structures. For one thing, the shape of a polygon is contiguous from start to end 31 * (compared to an arbitrary Path object, which could have one or more `moveTo` operations in the 32 * shape). Also, all edges of a polygon shape are represented by [Cubic] objects, thus the start and 33 * end shapes use similar operations. Two Polygon shapes then only differ in the quantity and 34 * placement of their curves. The morph works by determining how to map the curves of the two shapes 35 * together (based on proximity and other information, such as distance to polygon vertices and 36 * concavity), and splitting curves when the shapes do not have the same number of curves or when 37 * the curve placement within the shapes is very different. 38 */ 39 class Morph(private val start: RoundedPolygon, private val end: RoundedPolygon) { 40 /** 41 * The structure which holds the actual shape being morphed. It contains all cubics necessary to 42 * represent the start and end shapes (the original cubics in the shapes may be cut to align the 43 * start/end shapes), matched one to one in each Pair. 44 */ 45 @PublishedApi 46 internal val morphMatch: List<Pair<Cubic, Cubic>> 47 get() = _morphMatch 48 49 private val _morphMatch: List<Pair<Cubic, Cubic>> = match(start, end) 50 51 /** 52 * Calculates the axis-aligned bounds of the object. 53 * 54 * @param approximate when true, uses a faster calculation to create the bounding box based on 55 * the min/max values of all anchor and control points that make up the shape. Default value 56 * is true. 57 * @param bounds a buffer to hold the results. If not supplied, a temporary buffer will be 58 * created. 59 * @return The axis-aligned bounding box for this object, where the rectangles left, top, right, 60 * and bottom values will be stored in entries 0, 1, 2, and 3, in that order. 61 */ 62 @JvmOverloads 63 fun calculateBounds( 64 bounds: FloatArray = FloatArray(4), 65 approximate: Boolean = true 66 ): FloatArray { 67 start.calculateBounds(bounds, approximate) 68 val minX = bounds[0] 69 val minY = bounds[1] 70 val maxX = bounds[2] 71 val maxY = bounds[3] 72 end.calculateBounds(bounds, approximate) 73 bounds[0] = min(minX, bounds[0]) 74 bounds[1] = min(minY, bounds[1]) 75 bounds[2] = max(maxX, bounds[2]) 76 bounds[3] = max(maxY, bounds[3]) 77 return bounds 78 } 79 80 /** 81 * Like [calculateBounds], this function calculates the axis-aligned bounds of the object and 82 * returns that rectangle. But this function determines the max dimension of the shape (by 83 * calculating the distance from its center to the start and midpoint of each curve) and returns 84 * a square which can be used to hold the object in any rotation. This function can be used, for 85 * example, to calculate the max size of a UI element meant to hold this shape in any rotation. 86 * 87 * @param bounds a buffer to hold the results. If not supplied, a temporary buffer will be 88 * created. 89 * @return The axis-aligned max bounding box for this object, where the rectangles left, top, 90 * right, and bottom values will be stored in entries 0, 1, 2, and 3, in that order. 91 */ 92 fun calculateMaxBounds(bounds: FloatArray = FloatArray(4)): FloatArray { 93 start.calculateMaxBounds(bounds) 94 val minX = bounds[0] 95 val minY = bounds[1] 96 val maxX = bounds[2] 97 val maxY = bounds[3] 98 end.calculateMaxBounds(bounds) 99 bounds[0] = min(minX, bounds[0]) 100 bounds[1] = min(minY, bounds[1]) 101 bounds[2] = max(maxX, bounds[2]) 102 bounds[3] = max(maxY, bounds[3]) 103 return bounds 104 } 105 106 /** 107 * Returns a representation of the morph object at a given [progress] value as a list of Cubics. 108 * Note that this function causes a new list to be created and populated, so there is some 109 * overhead. 110 * 111 * @param progress a value from 0 to 1 that determines the morph's current shape, between the 112 * start and end shapes provided at construction time. A value of 0 results in the start 113 * shape, a value of 1 results in the end shape, and any value in between results in a shape 114 * which is a linear interpolation between those two shapes. The range is generally [0..1] and 115 * values outside could result in undefined shapes, but values close to (but outside) the 116 * range can be used to get an exaggerated effect (e.g., for a bounce or overshoot animation). 117 */ 118 fun asCubics(progress: Float): List<Cubic> { 119 return buildList { 120 // The first/last mechanism here ensures that the final anchor point in the shape 121 // exactly matches the first anchor point. There can be rendering artifacts introduced 122 // by those points being slightly off, even by much less than a pixel 123 var firstCubic: Cubic? = null 124 var lastCubic: Cubic? = null 125 for (i in _morphMatch.indices) { 126 val cubic = 127 Cubic( 128 FloatArray(8) { 129 interpolate( 130 _morphMatch[i].first.points[it], 131 _morphMatch[i].second.points[it], 132 progress 133 ) 134 } 135 ) 136 if (firstCubic == null) firstCubic = cubic 137 if (lastCubic != null) add(lastCubic) 138 lastCubic = cubic 139 } 140 if (lastCubic != null && firstCubic != null) 141 add( 142 Cubic( 143 lastCubic.anchor0X, 144 lastCubic.anchor0Y, 145 lastCubic.control0X, 146 lastCubic.control0Y, 147 lastCubic.control1X, 148 lastCubic.control1Y, 149 firstCubic.anchor0X, 150 firstCubic.anchor0Y 151 ) 152 ) 153 } 154 } 155 156 /** 157 * Returns a representation of the morph object at a given [progress] value, iterating over the 158 * cubics and calling the callback. This function is faster than [asCubics], since it doesn't 159 * allocate new [Cubic] instances, but to do this it reuses the same [MutableCubic] instance 160 * during iteration. 161 * 162 * @param progress a value from 0 to 1 that determines the morph's current shape, between the 163 * start and end shapes provided at construction time. A value of 0 results in the start 164 * shape, a value of 1 results in the end shape, and any value in between results in a shape 165 * which is a linear interpolation between those two shapes. The range is generally [0..1] and 166 * values outside could result in undefined shapes, but values close to (but outside) the 167 * range can be used to get an exaggerated effect (e.g., for a bounce or overshoot animation). 168 * @param mutableCubic An instance of [MutableCubic] that will be used to set each cubic in 169 * time. 170 * @param callback The function to be called for each Cubic 171 */ 172 @JvmOverloads 173 inline fun forEachCubic( 174 progress: Float, 175 mutableCubic: MutableCubic = MutableCubic(), 176 callback: (MutableCubic) -> Unit 177 ) { 178 for (i in morphMatch.indices) { 179 mutableCubic.interpolate(morphMatch[i].first, morphMatch[i].second, progress) 180 callback(mutableCubic) 181 } 182 } 183 184 internal companion object { 185 /** 186 * [match], called at Morph construction time, creates the structure used to animate between 187 * the start and end shapes. The technique is to match geometry (curves) between the shapes 188 * when and where possible, and to create new/placeholder curves when necessary (when one of 189 * the shapes has more curves than the other). The result is a list of pairs of Cubic 190 * curves. Those curves are the matched pairs: the first of each pair holds the geometry of 191 * the start shape, the second holds the geometry for the end shape. Changing the progress 192 * of a Morph object simply interpolates between all pairs of curves for the morph shape. 193 * 194 * Curves on both shapes are matched by running the [Measurer] to determine where the points 195 * are in each shape (proportionally, along the outline), and then running [featureMapper] 196 * which decides how to map (match) all of the curves with each other. 197 */ 198 @JvmStatic 199 internal fun match(p1: RoundedPolygon, p2: RoundedPolygon): List<Pair<Cubic, Cubic>> { 200 // TODO Commented out due to the use of javaClass ("Error: Platform reference in a 201 // common module") 202 /* 203 if (DEBUG) { 204 repeat(2) { polyIndex -> 205 debugLog(LOG_TAG) { 206 listOf("Initial start:\n", "Initial end:\n")[polyIndex] + 207 listOf(p1, p2)[polyIndex].features.joinToString("\n") { feature -> 208 "${feature.javaClass.name.split("$").last()} - " + 209 ((feature as? Feature.Corner)?.convex?.let { 210 if (it) "Convex - " else "Concave - " } ?: "") + 211 feature.cubics.joinToString("|") 212 } 213 } 214 } 215 } 216 */ 217 218 // Measure polygons, returns lists of measured cubics for each polygon, which 219 // we then use to match start/end curves 220 val measuredPolygon1 = MeasuredPolygon.measurePolygon(LengthMeasurer(), p1) 221 val measuredPolygon2 = MeasuredPolygon.measurePolygon(LengthMeasurer(), p2) 222 223 // features1 and 2 will contain the list of corners (just the inner circular curve) 224 // along with the progress at the middle of those corners. These measurement values 225 // are then used to compare and match between the two polygons 226 val features1 = measuredPolygon1.features 227 val features2 = measuredPolygon2.features 228 229 // Map features: doubleMapper is the result of mapping the features in each shape to the 230 // closest feature in the other shape. 231 // Given a progress in one of the shapes it can be used to find the corresponding 232 // progress in the other shape (in both directions) 233 val doubleMapper = featureMapper(features1, features2) 234 235 // cut point on poly2 is the mapping of the 0 point on poly1 236 val polygon2CutPoint = doubleMapper.map(0f) 237 debugLog(LOG_TAG) { "polygon2CutPoint = $polygon2CutPoint" } 238 239 // Cut and rotate. 240 // Polygons start at progress 0, and the featureMapper has decided that we want to match 241 // progress 0 in the first polygon to `polygon2CutPoint` on the second polygon. 242 // So we need to cut the second polygon there and "rotate it", so as we walk through 243 // both polygons we can find the matching. 244 // The resulting bs1/2 are MeasuredPolygons, whose MeasuredCubics start from 245 // outlineProgress=0 and increasing until outlineProgress=1 246 val bs1 = measuredPolygon1 247 val bs2 = measuredPolygon2.cutAndShift(polygon2CutPoint) 248 249 if (DEBUG) { 250 (0 until bs1.size).forEach { index -> 251 debugLog(LOG_TAG) { "start $index: ${bs1.getOrNull(index)}" } 252 } 253 (0 until bs2.size).forEach { index -> 254 debugLog(LOG_TAG) { "End $index: ${bs2.getOrNull(index)}" } 255 } 256 } 257 258 // Match 259 // Now we can compare the two lists of measured cubics and create a list of pairs 260 // of cubics [ret], which are the start/end curves that represent the Morph object 261 // and the start and end shapes, and which can be interpolated to animate the 262 // between those shapes. 263 val ret = mutableListOf<Pair<Cubic, Cubic>>() 264 // i1/i2 are the indices of the current cubic on the start (1) and end (2) shapes 265 var i1 = 0 266 var i2 = 0 267 // b1, b2 are the current measured cubic for each polygon 268 var b1 = bs1.getOrNull(i1++) 269 var b2 = bs2.getOrNull(i2++) 270 // Iterate until all curves are accounted for and matched 271 while (b1 != null && b2 != null) { 272 // Progresses are in shape1's perspective 273 // b1a, b2a are ending progress values of current measured cubics in [0,1] range 274 val b1a = if (i1 == bs1.size) 1f else b1.endOutlineProgress 275 val b2a = 276 if (i2 == bs2.size) 1f 277 else 278 doubleMapper.mapBack( 279 positiveModulo(b2.endOutlineProgress + polygon2CutPoint, 1f) 280 ) 281 val minb = min(b1a, b2a) 282 debugLog(LOG_TAG) { "$b1a $b2a | $minb" } 283 // min b is the progress at which the curve that ends first ends. 284 // If both curves ends roughly there, no cutting is needed, we have a match. 285 // If one curve extends beyond, we need to cut it. 286 val (seg1, newb1) = 287 if (b1a > minb + AngleEpsilon) { 288 debugLog(LOG_TAG) { "Cut 1" } 289 b1.cutAtProgress(minb) 290 } else { 291 b1 to bs1.getOrNull(i1++) 292 } 293 val (seg2, newb2) = 294 if (b2a > minb + AngleEpsilon) { 295 debugLog(LOG_TAG) { "Cut 2" } 296 b2.cutAtProgress( 297 positiveModulo(doubleMapper.map(minb) - polygon2CutPoint, 1f) 298 ) 299 } else { 300 b2 to bs2.getOrNull(i2++) 301 } 302 debugLog(LOG_TAG) { "Match: $seg1 -> $seg2" } 303 ret.add(seg1.cubic to seg2.cubic) 304 b1 = newb1 305 b2 = newb2 306 } 307 require(b1 == null && b2 == null) { 308 "Expected both Polygon's Cubic to be fully matched" 309 } 310 311 if (DEBUG) { 312 // Export as SVG path. 313 val showPoint: (Point) -> String = { 314 "${(it.x * 100).toStringWithLessPrecision()} ${(it.y * 100).toStringWithLessPrecision()}" 315 } 316 repeat(2) { listIx -> 317 val points = ret.map { if (listIx == 0) it.first else it.second } 318 debugLog(LOG_TAG) { 319 "M " + 320 showPoint(Point(points.first().anchor0X, points.first().anchor0Y)) + 321 " " + 322 points.joinToString(" ") { 323 "C " + 324 showPoint(Point(it.control0X, it.control0Y)) + 325 ", " + 326 showPoint(Point(it.control1X, it.control1Y)) + 327 ", " + 328 showPoint(Point(it.anchor1X, it.anchor1Y)) 329 } + 330 " Z" 331 } 332 } 333 } 334 return ret 335 } 336 } 337 } 338 339 private val LOG_TAG = "Morph" 340