1 /*
<lambda>null2  * Copyright 2019 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.graphics.vector
18 
19 import androidx.compose.runtime.getValue
20 import androidx.compose.runtime.mutableStateOf
21 import androidx.compose.runtime.setValue
22 import androidx.compose.ui.geometry.Offset
23 import androidx.compose.ui.geometry.Size
24 import androidx.compose.ui.geometry.Size.Companion.Unspecified
25 import androidx.compose.ui.graphics.BlendMode
26 import androidx.compose.ui.graphics.BlendModeColorFilter
27 import androidx.compose.ui.graphics.Brush
28 import androidx.compose.ui.graphics.Color
29 import androidx.compose.ui.graphics.ColorFilter
30 import androidx.compose.ui.graphics.ImageBitmapConfig
31 import androidx.compose.ui.graphics.Matrix
32 import androidx.compose.ui.graphics.Path
33 import androidx.compose.ui.graphics.PathFillType
34 import androidx.compose.ui.graphics.PathMeasure
35 import androidx.compose.ui.graphics.SolidColor
36 import androidx.compose.ui.graphics.StrokeCap
37 import androidx.compose.ui.graphics.StrokeJoin
38 import androidx.compose.ui.graphics.drawscope.DrawScope
39 import androidx.compose.ui.graphics.drawscope.Stroke
40 import androidx.compose.ui.graphics.drawscope.scale
41 import androidx.compose.ui.graphics.drawscope.withTransform
42 import androidx.compose.ui.graphics.isSpecified
43 import androidx.compose.ui.graphics.isUnspecified
44 import androidx.compose.ui.unit.IntSize
45 import androidx.compose.ui.util.fastForEach
46 import kotlin.math.ceil
47 
48 const val DefaultGroupName = ""
49 const val DefaultRotation = 0.0f
50 const val DefaultPivotX = 0.0f
51 const val DefaultPivotY = 0.0f
52 const val DefaultScaleX = 1.0f
53 const val DefaultScaleY = 1.0f
54 const val DefaultTranslationX = 0.0f
55 const val DefaultTranslationY = 0.0f
56 
57 val EmptyPath = emptyList<PathNode>()
58 
59 const val DefaultPathName = ""
60 const val DefaultStrokeLineWidth = 0.0f
61 const val DefaultStrokeLineMiter = 4.0f
62 const val DefaultTrimPathStart = 0.0f
63 const val DefaultTrimPathEnd = 1.0f
64 const val DefaultTrimPathOffset = 0.0f
65 
66 val DefaultStrokeLineCap = StrokeCap.Butt
67 val DefaultStrokeLineJoin = StrokeJoin.Miter
68 val DefaultTintBlendMode = BlendMode.SrcIn
69 val DefaultTintColor = Color.Transparent
70 val DefaultFillType = PathFillType.NonZero
71 
72 inline fun PathData(block: PathBuilder.() -> Unit) =
73     with(PathBuilder()) {
74         block()
75         nodes
76     }
77 
addPathNodesnull78 fun addPathNodes(pathStr: String?) =
79     if (pathStr == null) {
80         EmptyPath
81     } else {
82         PathParser().parsePathString(pathStr).toNodes()
83     }
84 
85 sealed class VNode {
86     /**
87      * Callback invoked whenever the node in the vector tree is modified in a way that would change
88      * the output of the Vector
89      */
90     internal open var invalidateListener: ((VNode) -> Unit)? = null
91 
invalidatenull92     fun invalidate() {
93         invalidateListener?.invoke(this)
94     }
95 
DrawScopenull96     abstract fun DrawScope.draw()
97 }
98 
99 internal class VectorComponent(val root: GroupComponent) : VNode() {
100 
101     init {
102         root.invalidateListener = { doInvalidate() }
103     }
104 
105     var name: String = DefaultGroupName
106 
107     private fun doInvalidate() {
108         isDirty = true
109         invalidateCallback.invoke()
110     }
111 
112     private var isDirty = true
113 
114     private val cacheDrawScope = DrawCache()
115 
116     internal val cacheBitmapConfig: ImageBitmapConfig
117         get() = cacheDrawScope.mCachedImage?.config ?: ImageBitmapConfig.Argb8888
118 
119     internal var invalidateCallback = {}
120 
121     internal var intrinsicColorFilter: ColorFilter? by mutableStateOf(null)
122 
123     // Conditional filter used if the vector is all one color. In this case we allocate a
124     // alpha8 channel bitmap and tint the result to the desired color
125     private var tintFilter: ColorFilter? = null
126 
127     internal var viewportSize by mutableStateOf(Size.Zero)
128 
129     private var previousDrawSize = Unspecified
130 
131     private var rootScaleX = 1f
132     private var rootScaleY = 1f
133 
134     /** Cached lambda used to avoid allocating the lambda on each draw invocation */
135     private val drawVectorBlock: DrawScope.() -> Unit = {
136         with(root) { scale(rootScaleX, rootScaleY, pivot = Offset.Zero) { draw() } }
137     }
138 
139     fun DrawScope.draw(alpha: Float, colorFilter: ColorFilter?) {
140         // If the content of the vector has changed, or we are drawing a different size
141         // update the cached image to ensure we are scaling the vector appropriately
142         val isOneColor = root.isTintable && root.tintColor.isSpecified
143         val targetImageConfig =
144             if (
145                 isOneColor &&
146                     intrinsicColorFilter.tintableWithAlphaMask() &&
147                     colorFilter.tintableWithAlphaMask()
148             ) {
149                 ImageBitmapConfig.Alpha8
150             } else {
151                 ImageBitmapConfig.Argb8888
152             }
153 
154         if (isDirty || previousDrawSize != size || targetImageConfig != cacheBitmapConfig) {
155             tintFilter =
156                 if (targetImageConfig == ImageBitmapConfig.Alpha8) {
157                     ColorFilter.tint(root.tintColor)
158                 } else {
159                     null
160                 }
161             rootScaleX = size.width / viewportSize.width
162             rootScaleY = size.height / viewportSize.height
163             cacheDrawScope.drawCachedImage(
164                 targetImageConfig,
165                 IntSize(ceil(size.width).toInt(), ceil(size.height).toInt()),
166                 this@draw,
167                 layoutDirection,
168                 drawVectorBlock
169             )
170             isDirty = false
171             previousDrawSize = size
172         }
173         val targetFilter =
174             if (colorFilter != null) {
175                 colorFilter
176             } else if (intrinsicColorFilter != null) {
177                 intrinsicColorFilter
178             } else {
179                 tintFilter
180             }
181         cacheDrawScope.drawInto(this, alpha, targetFilter)
182     }
183 
184     override fun DrawScope.draw() {
185         draw(1.0f, null)
186     }
187 
188     override fun toString(): String {
189         return buildString {
190             append("Params: ")
191             append("\tname: ").append(name).append("\n")
192             append("\tviewportWidth: ").append(viewportSize.width).append("\n")
193             append("\tviewportHeight: ").append(viewportSize.height).append("\n")
194         }
195     }
196 }
197 
198 internal class PathComponent : VNode() {
199     var name = DefaultPathName
200         set(value) {
201             field = value
202             invalidate()
203         }
204 
205     var fill: Brush? = null
206         set(value) {
207             field = value
208             invalidate()
209         }
210 
211     var fillAlpha = 1.0f
212         set(value) {
213             field = value
214             invalidate()
215         }
216 
217     var pathData = EmptyPath
218         set(value) {
219             field = value
220             isPathDirty = true
221             invalidate()
222         }
223 
224     var pathFillType = DefaultFillType
225         set(value) {
226             field = value
227             renderPath.fillType = value
228             invalidate()
229         }
230 
231     var strokeAlpha = 1.0f
232         set(value) {
233             field = value
234             invalidate()
235         }
236 
237     var strokeLineWidth = DefaultStrokeLineWidth
238         set(value) {
239             field = value
240             isStrokeDirty = true
241             invalidate()
242         }
243 
244     var stroke: Brush? = null
245         set(value) {
246             field = value
247             invalidate()
248         }
249 
250     var strokeLineCap = DefaultStrokeLineCap
251         set(value) {
252             field = value
253             isStrokeDirty = true
254             invalidate()
255         }
256 
257     var strokeLineJoin = DefaultStrokeLineJoin
258         set(value) {
259             field = value
260             isStrokeDirty = true
261             invalidate()
262         }
263 
264     var strokeLineMiter = DefaultStrokeLineMiter
265         set(value) {
266             field = value
267             isStrokeDirty = true
268             invalidate()
269         }
270 
271     var trimPathStart = DefaultTrimPathStart
272         set(value) {
273             field = value
274             isTrimPathDirty = true
275             invalidate()
276         }
277 
278     var trimPathEnd = DefaultTrimPathEnd
279         set(value) {
280             field = value
281             isTrimPathDirty = true
282             invalidate()
283         }
284 
285     var trimPathOffset = DefaultTrimPathOffset
286         set(value) {
287             field = value
288             isTrimPathDirty = true
289             invalidate()
290         }
291 
292     private var isPathDirty = true
293     private var isStrokeDirty = true
294     private var isTrimPathDirty = false
295 
296     private var strokeStyle: Stroke? = null
297 
298     private val path = Path()
299     private var renderPath = path
300 
<lambda>null301     private val pathMeasure: PathMeasure by lazy(LazyThreadSafetyMode.NONE) { PathMeasure() }
302 
updatePathnull303     private fun updatePath() {
304         // The call below resets the path
305         pathData.toPath(path)
306         updateRenderPath()
307     }
308 
updateRenderPathnull309     private fun updateRenderPath() {
310         if (trimPathStart == DefaultTrimPathStart && trimPathEnd == DefaultTrimPathEnd) {
311             renderPath = path
312         } else {
313             if (renderPath == path) {
314                 renderPath = Path()
315             } else {
316                 // Rewind unsets the fill type so reset it here
317                 val fillType = renderPath.fillType
318                 renderPath.rewind()
319                 renderPath.fillType = fillType
320             }
321 
322             pathMeasure.setPath(path, false)
323             val length = pathMeasure.length
324             val start = ((trimPathStart + trimPathOffset) % 1f) * length
325             val end = ((trimPathEnd + trimPathOffset) % 1f) * length
326             if (start > end) {
327                 pathMeasure.getSegment(start, length, renderPath, true)
328                 pathMeasure.getSegment(0f, end, renderPath, true)
329             } else {
330                 pathMeasure.getSegment(start, end, renderPath, true)
331             }
332         }
333     }
334 
drawnull335     override fun DrawScope.draw() {
336         if (isPathDirty) {
337             updatePath()
338         } else if (isTrimPathDirty) {
339             updateRenderPath()
340         }
341         isPathDirty = false
342         isTrimPathDirty = false
343 
344         fill?.let { drawPath(renderPath, brush = it, alpha = fillAlpha) }
345         stroke?.let {
346             var targetStroke = strokeStyle
347             if (isStrokeDirty || targetStroke == null) {
348                 targetStroke =
349                     Stroke(strokeLineWidth, strokeLineMiter, strokeLineCap, strokeLineJoin)
350                 strokeStyle = targetStroke
351                 isStrokeDirty = false
352             }
353             drawPath(renderPath, brush = it, alpha = strokeAlpha, style = targetStroke)
354         }
355     }
356 
toStringnull357     override fun toString() = path.toString()
358 }
359 
360 internal class GroupComponent : VNode() {
361     private var groupMatrix: Matrix? = null
362 
363     private val children = mutableListOf<VNode>()
364 
365     /**
366      * Flag to determine if the contents of this group can be rendered with a single color This is
367      * true if all the paths and groups within this group can be rendered with the same color
368      */
369     var isTintable = true
370         private set
371 
372     /**
373      * Tint color to render all the contents of this group. This is configured only if all the
374      * contents within the group are the same color
375      */
376     var tintColor = Color.Unspecified
377         private set
378 
379     /**
380      * Helper method to inspect whether the provided brush matches the current color of paths within
381      * the group in order to help determine if only an alpha channel bitmap can be allocated and
382      * tinted in order to save on memory overhead.
383      */
384     private fun markTintForBrush(brush: Brush?) {
385         if (!isTintable) {
386             return
387         }
388         if (brush != null) {
389             if (brush is SolidColor) {
390                 markTintForColor(brush.value)
391             } else {
392                 // If the brush is not a solid color then we require a explicit ARGB channels in the
393                 // cached bitmap
394                 markNotTintable()
395             }
396         }
397     }
398 
399     /**
400      * Helper method to inspect whether the provided color matches the current color of paths within
401      * the group in order to help determine if only an alpha channel bitmap can be allocated and
402      * tinted in order to save on memory overhead.
403      */
404     private fun markTintForColor(color: Color) {
405         if (!isTintable) {
406             return
407         }
408 
409         if (color.isSpecified) {
410             if (tintColor.isUnspecified) {
411                 // Initial color has not been specified, initialize the target color to the
412                 // one provided
413                 tintColor = color
414             } else if (!tintColor.rgbEqual(color)) {
415                 // The given color does not match the rgb channels if our previous color
416                 // Therefore we require explicit ARGB channels in the cached bitmap
417                 markNotTintable()
418             }
419         }
420     }
421 
422     private fun markTintForVNode(node: VNode) {
423         if (node is PathComponent) {
424             markTintForBrush(node.fill)
425             markTintForBrush(node.stroke)
426         } else if (node is GroupComponent) {
427             if (node.isTintable && isTintable) {
428                 markTintForColor(node.tintColor)
429             } else {
430                 markNotTintable()
431             }
432         }
433     }
434 
435     private fun markNotTintable() {
436         isTintable = false
437         tintColor = Color.Unspecified
438     }
439 
440     var clipPathData = EmptyPath
441         set(value) {
442             field = value
443             isClipPathDirty = true
444             invalidate()
445         }
446 
447     private val willClipPath: Boolean
448         get() = clipPathData.isNotEmpty()
449 
450     private var isClipPathDirty = true
451 
452     private var clipPath: Path? = null
453 
454     override var invalidateListener: ((VNode) -> Unit)? = null
455 
456     private val wrappedListener: (VNode) -> Unit = { node ->
457         markTintForVNode(node)
458         invalidateListener?.invoke(node)
459     }
460 
461     private fun updateClipPath() {
462         if (willClipPath) {
463             var targetClip = clipPath
464             if (targetClip == null) {
465                 targetClip = Path()
466                 clipPath = targetClip
467             }
468 
469             // toPath() will reset the path we send
470             clipPathData.toPath(targetClip)
471         }
472     }
473 
474     // If the name changes we should re-draw as individual nodes could
475     // be modified based off of this name parameter.
476     var name = DefaultGroupName
477         set(value) {
478             field = value
479             invalidate()
480         }
481 
482     var rotation = DefaultRotation
483         set(value) {
484             field = value
485             isMatrixDirty = true
486             invalidate()
487         }
488 
489     var pivotX = DefaultPivotX
490         set(value) {
491             field = value
492             isMatrixDirty = true
493             invalidate()
494         }
495 
496     var pivotY = DefaultPivotY
497         set(value) {
498             field = value
499             isMatrixDirty = true
500             invalidate()
501         }
502 
503     var scaleX = DefaultScaleX
504         set(value) {
505             field = value
506             isMatrixDirty = true
507             invalidate()
508         }
509 
510     var scaleY = DefaultScaleY
511         set(value) {
512             field = value
513             isMatrixDirty = true
514             invalidate()
515         }
516 
517     var translationX = DefaultTranslationX
518         set(value) {
519             field = value
520             isMatrixDirty = true
521             invalidate()
522         }
523 
524     var translationY = DefaultTranslationY
525         set(value) {
526             field = value
527             isMatrixDirty = true
528             invalidate()
529         }
530 
531     val numChildren: Int
532         get() = children.size
533 
534     private var isMatrixDirty = true
535 
536     private fun updateMatrix() {
537         val matrix: Matrix
538         val target = groupMatrix
539         if (target == null) {
540             matrix = Matrix()
541             groupMatrix = matrix
542         } else {
543             matrix = target
544             matrix.reset()
545         }
546         // M = T(translationX + pivotX, translationY + pivotY) *
547         //     R(rotation) * S(scaleX, scaleY) *
548         //     T(-pivotX, -pivotY)
549         matrix.translate(translationX + pivotX, translationY + pivotY)
550         matrix.rotateZ(degrees = rotation)
551         matrix.scale(scaleX, scaleY, 1f)
552         matrix.translate(-pivotX, -pivotY)
553     }
554 
555     fun insertAt(index: Int, instance: VNode) {
556         if (index < numChildren) {
557             children[index] = instance
558         } else {
559             children.add(instance)
560         }
561 
562         markTintForVNode(instance)
563 
564         instance.invalidateListener = wrappedListener
565         invalidate()
566     }
567 
568     fun move(from: Int, to: Int, count: Int) {
569         if (from > to) {
570             var current = to
571             repeat(count) {
572                 val node = children[from]
573                 children.removeAt(from)
574                 children.add(current, node)
575                 current++
576             }
577         } else {
578             repeat(count) {
579                 val node = children[from]
580                 children.removeAt(from)
581                 children.add(to - 1, node)
582             }
583         }
584         invalidate()
585     }
586 
587     fun remove(index: Int, count: Int) {
588         repeat(count) {
589             if (index < children.size) {
590                 children[index].invalidateListener = null
591                 children.removeAt(index)
592             }
593         }
594         invalidate()
595     }
596 
597     override fun DrawScope.draw() {
598         if (isMatrixDirty) {
599             updateMatrix()
600             isMatrixDirty = false
601         }
602 
603         if (isClipPathDirty) {
604             updateClipPath()
605             isClipPathDirty = false
606         }
607 
608         withTransform({
609             groupMatrix?.let { transform(it) }
610             val targetClip = clipPath
611             if (willClipPath && targetClip != null) {
612                 clipPath(targetClip)
613             }
614         }) {
615             children.fastForEach { node -> with(node) { this@draw.draw() } }
616         }
617     }
618 
619     override fun toString(): String {
620         val sb = StringBuilder().append("VGroup: ").append(name)
621         children.fastForEach { node -> sb.append("\t").append(node.toString()).append("\n") }
622         return sb.toString()
623     }
624 }
625 
626 /**
627  * helper method to verify if the rgb channels are equal excluding comparison of the alpha channel
628  */
rgbEqualnull629 internal fun Color.rgbEqual(other: Color) =
630     this.red == other.red && this.green == other.green && this.blue == other.blue
631 
632 /**
633  * Helper method to determine if a particular ColorFilter will generate the same output if the
634  * bitmap has an Alpha8 or ARGB8888 configuration
635  */
636 internal fun ColorFilter?.tintableWithAlphaMask() =
637     if (this is BlendModeColorFilter) {
638         this.blendMode == BlendMode.SrcIn || this.blendMode == BlendMode.SrcOver
639     } else {
640         this == null
641     }
642