1 /* 2 * Copyright 2020 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.compose.ui.platform 18 19 import android.graphics.Outline as AndroidOutline 20 import android.os.Build 21 import androidx.annotation.DoNotInline 22 import androidx.annotation.RequiresApi 23 import androidx.compose.ui.geometry.CornerRadius 24 import androidx.compose.ui.geometry.Offset 25 import androidx.compose.ui.geometry.Rect 26 import androidx.compose.ui.geometry.RoundRect 27 import androidx.compose.ui.geometry.Size 28 import androidx.compose.ui.geometry.isSimple 29 import androidx.compose.ui.graphics.Canvas 30 import androidx.compose.ui.graphics.Outline 31 import androidx.compose.ui.graphics.Path 32 import androidx.compose.ui.graphics.asAndroidPath 33 import androidx.compose.ui.util.fastRoundToInt 34 35 /** Resolves the [AndroidOutline] from the [Outline] of an [androidx.compose.ui.node.OwnedLayer]. */ 36 internal class OutlineResolver { 37 38 /** 39 * Flag to determine if the shape specified on the outline is supported. On older API levels, 40 * concave shapes are not allowed 41 */ 42 private var isSupportedOutline = true 43 44 /** The Android Outline that is used in the layer. */ <lambda>null45 private val cachedOutline = AndroidOutline().apply { alpha = 1f } 46 47 /** The [Outline] of the Layer. */ 48 private var outline: Outline? = null 49 50 /** 51 * Asymmetric rounded rectangles need to use a Path. This caches that Path so that a new one 52 * doesn't have to be generated each time. 53 */ 54 // TODO(andreykulikov): Make Outline API reuse the Path when generating. 55 private var cachedRrectPath: Path? = null // for temporary allocation in rounded rects 56 57 /** 58 * The outline Path when a non-conforming (rect or symmetric rounded rect) Outline is used. This 59 * Path is necessary when [usePathForClip] is true to indicate the Path to clip in [clipPath]. 60 */ 61 private var outlinePath: Path? = null 62 63 /** 64 * True when there's been an update that caused a change in the path and the Outline has to be 65 * reevaluated. 66 */ 67 internal var cacheIsDirty = false 68 private set 69 70 /** 71 * True when Outline cannot clip the content and the path should be used instead. This is when 72 * an asymmetric rounded rect or general Path is used in the outline. This is false when a Rect 73 * or a symmetric RoundRect is used in the outline. 74 */ 75 private var usePathForClip = false 76 77 /** Scratch path used for manually clipping in software backed canvases */ 78 private var tmpPath: Path? = null 79 80 /** Scratch [RoundRect] used for manually clipping round rects in software backed canvases */ 81 private var tmpRoundRect: RoundRect? = null 82 83 /** 84 * Radius value used for symmetric rounded shapes. For rectangular or path based outlines this 85 * value is 0f 86 */ 87 private var roundedCornerRadius: Float = 0f 88 89 /** Returns the Android Outline to be used in the layer. */ 90 val androidOutline: AndroidOutline? 91 get() { 92 updateCache() 93 return if (!outlineNeeded || !isSupportedOutline) null else cachedOutline 94 } 95 96 /** 97 * Determines if the particular outline shape or path supports clipping. True for rect or 98 * symmetrical round rects. This method is used to determine if the framework can handle 99 * clipping to the outline for a particular shape. If not, then the clipped path must be applied 100 * directly to the canvas. 101 */ 102 val outlineClipSupported: Boolean 103 get() = !usePathForClip 104 105 /** 106 * Returns the path used to manually clip regardless if the layer supports clipping or not. In 107 * some cases (i.e. software rendering) clipping must be done manually. Consumers should query 108 * whether or not the layer will handle clipping with [outlineClipSupported] first before 109 * applying the clip manually. Or when rendering in software, the clip path provided here must 110 * always be clipped manually. 111 */ 112 val clipPath: Path? 113 get() { 114 updateCache() 115 return outlinePath 116 } 117 118 /** 119 * Returns the top left offset for a rectangular, or rounded rect outline (regardless if it is 120 * symmetric or asymmetric) For path based outlines this returns [Offset.Zero] 121 */ 122 private var rectTopLeft: Offset = Offset.Zero 123 124 /** 125 * Returns the size for a rectangular, or rounded rect outline (regardless if it is symmetric or 126 * asymmetric) 127 */ 128 private var rectSize: Size = Size.Zero 129 130 /** True when we are going to clip or have a non-zero elevation for shadows. */ 131 private var outlineNeeded = false 132 133 private var tmpTouchPointPath: Path? = null 134 private var tmpOpPath: Path? = null 135 136 /** Updates the values of the outline. Returns `true` when the shape has changed. */ updatenull137 fun update( 138 outline: Outline?, 139 alpha: Float, 140 clipToOutline: Boolean, 141 elevation: Float, 142 size: Size, 143 ): Boolean { 144 cachedOutline.alpha = alpha 145 val outlineChanged = this.outline != outline 146 if (outlineChanged) { 147 this.outline = outline 148 cacheIsDirty = true 149 } 150 this.rectSize = size 151 val outlineNeeded = outline != null && (clipToOutline || elevation > 0f) 152 if (this.outlineNeeded != outlineNeeded) { 153 this.outlineNeeded = outlineNeeded 154 cacheIsDirty = true 155 } 156 return outlineChanged 157 } 158 159 /** Returns true if there is a outline and [position] is outside the outline. */ isInOutlinenull160 fun isInOutline(position: Offset): Boolean { 161 if (!outlineNeeded) { 162 return true 163 } 164 val outline = outline ?: return true 165 166 return isInOutline(outline, position.x, position.y, tmpTouchPointPath, tmpOpPath) 167 } 168 169 /** 170 * Manually applies the clip to the provided canvas based on the given outline. This is used in 171 * scenarios where clipping must be applied manually either because the outline cannot be 172 * clipped automatically for specific shapes or if the layer is being rendered in software 173 */ clipToOutlinenull174 fun clipToOutline(canvas: Canvas) { 175 // If we have a clip path that means we are clipping to an arbitrary path or 176 // a rounded rect with non-uniform corner radii 177 val targetPath = clipPath 178 if (targetPath != null) { 179 canvas.clipPath(targetPath) 180 } else { 181 // If we have a non-zero radius, that means we are clipping to a symmetrical 182 // rounded rectangle. 183 // Canvas does not include a clipRoundRect API so create a path with the round rect 184 // and clip to the given path/ 185 if (roundedCornerRadius > 0f) { 186 var roundRectClipPath = tmpPath 187 var roundRect = tmpRoundRect 188 if ( 189 roundRectClipPath == null || 190 !roundRect.isSameBounds(rectTopLeft, rectSize, roundedCornerRadius) 191 ) { 192 roundRect = 193 RoundRect( 194 left = rectTopLeft.x, 195 top = rectTopLeft.y, 196 right = rectTopLeft.x + rectSize.width, 197 bottom = rectTopLeft.y + rectSize.height, 198 cornerRadius = CornerRadius(roundedCornerRadius) 199 ) 200 if (roundRectClipPath == null) { 201 roundRectClipPath = Path() 202 } else { 203 roundRectClipPath.reset() 204 } 205 roundRectClipPath.addRoundRect(roundRect) 206 tmpRoundRect = roundRect 207 tmpPath = roundRectClipPath 208 } 209 canvas.clipPath(roundRectClipPath) 210 } else { 211 // ... otherwise, just clip to the bounds of the rect 212 canvas.clipRect( 213 left = rectTopLeft.x, 214 top = rectTopLeft.y, 215 right = rectTopLeft.x + rectSize.width, 216 bottom = rectTopLeft.y + rectSize.height, 217 ) 218 } 219 } 220 } 221 updateCachenull222 private fun updateCache() { 223 if (cacheIsDirty) { 224 rectTopLeft = Offset.Zero 225 roundedCornerRadius = 0f 226 outlinePath = null 227 cacheIsDirty = false 228 usePathForClip = false 229 val outline = outline 230 if ( 231 outline != null && outlineNeeded && rectSize.width > 0.0f && rectSize.height > 0.0f 232 ) { 233 // Always assume the outline type is supported 234 // The methods to configure the outline will determine/update the flag 235 // if it not supported on the API level 236 isSupportedOutline = true 237 when (outline) { 238 is Outline.Rectangle -> updateCacheWithRect(outline.rect) 239 is Outline.Rounded -> updateCacheWithRoundRect(outline.roundRect) 240 is Outline.Generic -> updateCacheWithPath(outline.path) 241 } 242 } else { 243 cachedOutline.setEmpty() 244 } 245 } 246 } 247 updateCacheWithRectnull248 private fun updateCacheWithRect(rect: Rect) { 249 rectTopLeft = Offset(rect.left, rect.top) 250 rectSize = Size(rect.width, rect.height) 251 cachedOutline.setRect( 252 rect.left.fastRoundToInt(), 253 rect.top.fastRoundToInt(), 254 rect.right.fastRoundToInt(), 255 rect.bottom.fastRoundToInt() 256 ) 257 } 258 updateCacheWithRoundRectnull259 private fun updateCacheWithRoundRect(roundRect: RoundRect) { 260 val radius = roundRect.topLeftCornerRadius.x 261 rectTopLeft = Offset(roundRect.left, roundRect.top) 262 rectSize = Size(roundRect.width, roundRect.height) 263 if (roundRect.isSimple) { 264 cachedOutline.setRoundRect( 265 roundRect.left.fastRoundToInt(), 266 roundRect.top.fastRoundToInt(), 267 roundRect.right.fastRoundToInt(), 268 roundRect.bottom.fastRoundToInt(), 269 radius 270 ) 271 roundedCornerRadius = radius 272 } else { 273 val path = cachedRrectPath ?: Path().also { cachedRrectPath = it } 274 path.reset() 275 path.addRoundRect(roundRect) 276 updateCacheWithPath(path) 277 } 278 } 279 280 @Suppress("deprecation") updateCacheWithPathnull281 private fun updateCacheWithPath(composePath: Path) { 282 if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P || composePath.isConvex) { 283 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 284 OutlineVerificationHelper.setPath(cachedOutline, composePath) 285 } else { 286 cachedOutline.setConvexPath(composePath.asAndroidPath()) 287 } 288 usePathForClip = !cachedOutline.canClip() 289 } else { 290 isSupportedOutline = false // Concave outlines are not supported on older API levels 291 cachedOutline.setEmpty() 292 usePathForClip = true 293 } 294 outlinePath = composePath 295 } 296 297 /** 298 * Helper method to see if the RoundRect has the same bounds as the offset as well as the same 299 * corner radius. If the RoundRect does not have symmetrical corner radii this method always 300 * returns false 301 */ RoundRectnull302 private fun RoundRect?.isSameBounds(offset: Offset, size: Size, radius: Float): Boolean { 303 if (this == null || !isSimple) { 304 return false 305 } 306 return left == offset.x && 307 top == offset.y && 308 right == (offset.x + size.width) && 309 bottom == (offset.y + size.height) && 310 topLeftCornerRadius.x == radius 311 } 312 } 313 314 @RequiresApi(Build.VERSION_CODES.R) 315 internal object OutlineVerificationHelper { 316 317 @DoNotInline setPathnull318 fun setPath(outline: AndroidOutline, path: Path) { 319 outline.setPath(path.asAndroidPath()) 320 } 321 } 322