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