• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2018 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 package com.android.launcher3.graphics
17 
18 import android.animation.Animator
19 import android.animation.AnimatorListenerAdapter
20 import android.animation.ValueAnimator
21 import android.animation.ValueAnimator.AnimatorUpdateListener
22 import android.graphics.Canvas
23 import android.graphics.Color
24 import android.graphics.Matrix
25 import android.graphics.Matrix.ScaleToFit.FILL
26 import android.graphics.Paint
27 import android.graphics.Path
28 import android.graphics.Rect
29 import android.graphics.RectF
30 import android.graphics.Region
31 import android.graphics.drawable.AdaptiveIconDrawable
32 import android.graphics.drawable.ColorDrawable
33 import android.util.Log
34 import android.view.View
35 import android.view.ViewOutlineProvider
36 import androidx.annotation.VisibleForTesting
37 import androidx.core.graphics.PathParser
38 import androidx.graphics.shapes.CornerRounding
39 import androidx.graphics.shapes.Morph
40 import androidx.graphics.shapes.RoundedPolygon
41 import androidx.graphics.shapes.SvgPathParser
42 import androidx.graphics.shapes.rectangle
43 import androidx.graphics.shapes.toPath
44 import androidx.graphics.shapes.transformed
45 import com.android.launcher3.icons.GraphicsUtils
46 import com.android.launcher3.views.ClipPathView
47 
48 /** Abstract representation of the shape of an icon shape */
49 interface ShapeDelegate {
50 
51     fun getPath(pathSize: Float = DEFAULT_PATH_SIZE) =
52         Path().apply { addToPath(this, 0f, 0f, pathSize / 2) }
53 
54     fun getPath(bounds: Rect) =
55         Path().apply {
56             addToPath(
57                 this,
58                 bounds.left.toFloat(),
59                 bounds.top.toFloat(),
60                 // Radius is half of the average size of the icon
61                 (bounds.width() + bounds.height()) / 4f,
62             )
63         }
64 
65     fun drawShape(canvas: Canvas, offsetX: Float, offsetY: Float, radius: Float, paint: Paint)
66 
67     fun addToPath(path: Path, offsetX: Float, offsetY: Float, radius: Float)
68 
69     fun <T> createRevealAnimator(
70         target: T,
71         startRect: Rect,
72         endRect: Rect,
73         endRadius: Float,
74         isReversed: Boolean,
75     ): ValueAnimator where T : View, T : ClipPathView
76 
77     class Circle : RoundedSquare(1f) {
78 
79         override fun drawShape(
80             canvas: Canvas,
81             offsetX: Float,
82             offsetY: Float,
83             radius: Float,
84             paint: Paint,
85         ) = canvas.drawCircle(radius + offsetX, radius + offsetY, radius, paint)
86 
87         override fun addToPath(path: Path, offsetX: Float, offsetY: Float, radius: Float) =
88             path.addCircle(radius + offsetX, radius + offsetY, radius, Path.Direction.CW)
89     }
90 
91     /** Rounded square with [radiusRatio] as a ratio of its half edge size */
92     @VisibleForTesting
93     open class RoundedSquare(val radiusRatio: Float) : ShapeDelegate {
94 
95         override fun drawShape(
96             canvas: Canvas,
97             offsetX: Float,
98             offsetY: Float,
99             radius: Float,
100             paint: Paint,
101         ) {
102             val cx = radius + offsetX
103             val cy = radius + offsetY
104             val cr = radius * radiusRatio
105             canvas.drawRoundRect(cx - radius, cy - radius, cx + radius, cy + radius, cr, cr, paint)
106         }
107 
108         override fun addToPath(path: Path, offsetX: Float, offsetY: Float, radius: Float) {
109             val cx = radius + offsetX
110             val cy = radius + offsetY
111             val cr = radius * radiusRatio
112             path.addRoundRect(
113                 cx - radius,
114                 cy - radius,
115                 cx + radius,
116                 cy + radius,
117                 cr,
118                 cr,
119                 Path.Direction.CW,
120             )
121         }
122 
123         override fun <T> createRevealAnimator(
124             target: T,
125             startRect: Rect,
126             endRect: Rect,
127             endRadius: Float,
128             isReversed: Boolean,
129         ): ValueAnimator where T : View, T : ClipPathView {
130             val startRadius = (startRect.width() / 2f) * radiusRatio
131             return ClipAnimBuilder(target) { progress, path ->
132                 val radius = (1 - progress) * startRadius + progress * endRadius
133                 path.addRoundRect(
134                     (1 - progress) * startRect.left + progress * endRect.left,
135                     (1 - progress) * startRect.top + progress * endRect.top,
136                     (1 - progress) * startRect.right + progress * endRect.right,
137                     (1 - progress) * startRect.bottom + progress * endRect.bottom,
138                     radius,
139                     radius,
140                     Path.Direction.CW,
141                 )
142             }
143                 .toAnim(isReversed)
144         }
145 
146         override fun equals(other: Any?) =
147             other is RoundedSquare && other.radiusRatio == radiusRatio
148 
149         override fun hashCode() = radiusRatio.hashCode()
150     }
151 
152     /** Generic shape delegate with pathString in bounds [0, 0, 100, 100] */
153     data class GenericPathShape(private val pathString: String) : ShapeDelegate {
154         private val poly =
155             RoundedPolygon(
156                 features = SvgPathParser.parseFeatures(pathString),
157                 centerX = 50f,
158                 centerY = 50f,
159             )
160         // This ensures that a valid morph is possible from the provided path
161         private val basePath =
162             Path().apply {
163                 Morph(poly, createRoundedRect(0f, 0f, 100f, 100f, 25f)).toPath(0f, this)
164             }
165         private val tmpPath = Path()
166         private val tmpMatrix = Matrix()
167 
168         override fun drawShape(
169             canvas: Canvas,
170             offsetX: Float,
171             offsetY: Float,
172             radius: Float,
173             paint: Paint,
174         ) {
175             tmpPath.reset()
176             addToPath(tmpPath, offsetX, offsetY, radius, tmpMatrix)
177             canvas.drawPath(tmpPath, paint)
178         }
179 
180         override fun addToPath(path: Path, offsetX: Float, offsetY: Float, radius: Float) {
181             addToPath(path, offsetX, offsetY, radius, Matrix())
182         }
183 
184         private fun addToPath(
185             path: Path,
186             offsetX: Float,
187             offsetY: Float,
188             radius: Float,
189             matrix: Matrix,
190         ) {
191             matrix.setScale(radius / 50, radius / 50)
192             matrix.postTranslate(offsetX, offsetY)
193             basePath.transform(matrix, path)
194         }
195 
196         override fun <T> createRevealAnimator(
197             target: T,
198             startRect: Rect,
199             endRect: Rect,
200             endRadius: Float,
201             isReversed: Boolean,
202         ): ValueAnimator where T : View, T : ClipPathView {
203             // End poly is defined as a rectangle starting at top/center so that the
204             // transformation has minimum motion
205             val morph =
206                 Morph(
207                     start =
208                         poly.transformed(
209                             Matrix().apply {
210                                 setRectToRect(
211                                     RectF(0f, 0f, DEFAULT_PATH_SIZE, DEFAULT_PATH_SIZE),
212                                     RectF(startRect),
213                                     FILL,
214                                 )
215                             }
216                         ),
217                     end =
218                         createRoundedRect(
219                             left = endRect.left.toFloat(),
220                             top = endRect.top.toFloat(),
221                             right = endRect.right.toFloat(),
222                             bottom = endRect.bottom.toFloat(),
223                             cornerR = endRadius,
224                         ),
225                 )
226 
227             return ClipAnimBuilder(target, morph::toPath).toAnim(isReversed)
228         }
229     }
230 
231     private class ClipAnimBuilder<T>(val target: T, val pathProvider: (Float, Path) -> Unit) :
232         AnimatorListenerAdapter(), AnimatorUpdateListener where T : View, T : ClipPathView {
233 
234         private var oldOutlineProvider: ViewOutlineProvider? = null
235         val path = Path()
236 
237         override fun onAnimationStart(animation: Animator) {
238             target.apply {
239                 oldOutlineProvider = outlineProvider
240                 outlineProvider = null
241                 translationZ = -target.elevation
242             }
243         }
244 
245         override fun onAnimationEnd(animation: Animator) {
246             target.apply {
247                 translationZ = 0f
248                 setClipPath(null)
249                 outlineProvider = oldOutlineProvider
250             }
251         }
252 
253         override fun onAnimationUpdate(anim: ValueAnimator) {
254             path.reset()
255             pathProvider.invoke(anim.animatedValue as Float, path)
256             target.setClipPath(path)
257         }
258 
259         fun toAnim(isReversed: Boolean) =
260             (if (isReversed) ValueAnimator.ofFloat(1f, 0f) else ValueAnimator.ofFloat(0f, 1f))
261                 .also {
262                     it.addListener(this)
263                     it.addUpdateListener(this)
264                 }
265     }
266 
267     companion object {
268 
269         const val TAG = "IconShape"
270         const val DEFAULT_PATH_SIZE = 100f
271         const val AREA_CALC_SIZE = 1000
272         // .1% error margin
273         const val AREA_DIFF_THRESHOLD = AREA_CALC_SIZE * AREA_CALC_SIZE / 1000
274 
275         /** Returns a function to calculate area diff from [base] */
276         @VisibleForTesting
277         fun areaDiffCalculator(base: Path): (ShapeDelegate) -> Int {
278             val fullRegion = Region(0, 0, AREA_CALC_SIZE, AREA_CALC_SIZE)
279             val iconRegion = Region().apply { setPath(base, fullRegion) }
280 
281             val shapePath = Path()
282             val shapeRegion = Region()
283             return fun(shape: ShapeDelegate): Int {
284                 shapePath.reset()
285                 shape.addToPath(shapePath, 0f, 0f, AREA_CALC_SIZE / 2f)
286                 shapeRegion.setPath(shapePath, fullRegion)
287                 shapeRegion.op(iconRegion, Region.Op.XOR)
288                 return GraphicsUtils.getArea(shapeRegion)
289             }
290         }
291 
292         fun pickBestShape(shapeStr: String): ShapeDelegate {
293             val baseShape =
294                 if (shapeStr.isNotEmpty()) {
295                     PathParser.createPathFromPathData(shapeStr).apply {
296                         transform(
297                             Matrix().apply {
298                                 setScale(
299                                     AREA_CALC_SIZE / DEFAULT_PATH_SIZE,
300                                     AREA_CALC_SIZE / DEFAULT_PATH_SIZE,
301                                 )
302                             }
303                         )
304                     }
305                 } else {
306                     AdaptiveIconDrawable(null, ColorDrawable(Color.BLACK)).let {
307                         it.setBounds(0, 0, AREA_CALC_SIZE, AREA_CALC_SIZE)
308                         it.iconMask
309                     }
310                 }
311             return pickBestShape(baseShape, shapeStr)
312         }
313 
314         fun pickBestShape(baseShape: Path, shapeStr: String): ShapeDelegate {
315             val calcAreaDiff = areaDiffCalculator(baseShape)
316 
317             // Find the shape with minimum area of divergent region.
318             var closestShape: ShapeDelegate = Circle()
319             var minAreaDiff = calcAreaDiff(closestShape)
320 
321             // Try some common rounded rect edges
322             for (f in 0..20) {
323                 val rectShape = RoundedSquare(f.toFloat() / 20)
324                 val rectArea = calcAreaDiff(rectShape)
325                 if (rectArea < minAreaDiff) {
326                     minAreaDiff = rectArea
327                     closestShape = rectShape
328                 }
329             }
330 
331             // Use the generic shape only if we have more than .1% error
332             if (shapeStr.isNotEmpty() && minAreaDiff > AREA_DIFF_THRESHOLD) {
333                 try {
334                     val generic = GenericPathShape(shapeStr)
335                     closestShape = generic
336                 } catch (e: Exception) {
337                     Log.e(TAG, "Error converting mask to generic shape", e)
338                 }
339             }
340             return closestShape
341         }
342 
343         /**
344          * Create RoundedRect using RoundedPolygon API. Ensures smoother animation morphing between
345          * generic polygon by using [RoundedPolygon.Companion.rectangle] directly.
346          */
347         fun createRoundedRect(
348             left: Float,
349             top: Float,
350             right: Float,
351             bottom: Float,
352             cornerR: Float,
353         ) =
354             RoundedPolygon.rectangle(
355                 width = right - left,
356                 height = bottom - top,
357                 centerX = (right - left) / 2,
358                 centerY = (bottom - top) / 2,
359                 rounding = CornerRounding(cornerR),
360             )
361     }
362 }
363