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