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