|
16 | 16 |
|
17 | 17 | package com.example.android.compose.recomposehighlighter
|
18 | 18 |
|
19 |
| -import androidx.compose.runtime.LaunchedEffect |
20 | 19 | import androidx.compose.runtime.Stable
|
21 |
| -import androidx.compose.runtime.mutableStateOf |
22 |
| -import androidx.compose.runtime.remember |
23 | 20 | import androidx.compose.ui.Modifier
|
24 |
| -import androidx.compose.ui.composed |
25 |
| -import androidx.compose.ui.draw.drawWithCache |
26 | 21 | import androidx.compose.ui.geometry.Offset
|
27 | 22 | import androidx.compose.ui.geometry.Size
|
28 | 23 | import androidx.compose.ui.graphics.Color
|
29 | 24 | import androidx.compose.ui.graphics.SolidColor
|
| 25 | +import androidx.compose.ui.graphics.drawscope.ContentDrawScope |
30 | 26 | import androidx.compose.ui.graphics.drawscope.Fill
|
31 | 27 | import androidx.compose.ui.graphics.drawscope.Stroke
|
32 | 28 | import androidx.compose.ui.graphics.lerp
|
| 29 | +import androidx.compose.ui.node.DrawModifierNode |
| 30 | +import androidx.compose.ui.node.ModifierNodeElement |
| 31 | +import androidx.compose.ui.node.invalidateDraw |
| 32 | +import androidx.compose.ui.platform.InspectorInfo |
33 | 33 | import androidx.compose.ui.platform.debugInspectorInfo
|
34 | 34 | import androidx.compose.ui.unit.dp
|
| 35 | +import java.util.Objects |
35 | 36 | import kotlin.math.min
|
| 37 | +import kotlinx.coroutines.Job |
36 | 38 | import kotlinx.coroutines.delay
|
| 39 | +import kotlinx.coroutines.launch |
37 | 40 |
|
38 | 41 | /**
|
39 | 42 | * A [Modifier] that draws a border around elements that are recomposing. The border increases in
|
40 | 43 | * size and interpolates from red to green as more recompositions occur before a timeout.
|
41 | 44 | */
|
42 | 45 | @Stable
|
43 |
| -fun Modifier.recomposeHighlighter(): Modifier = this.then(recomposeModifier) |
44 |
| - |
45 |
| -// Use a single instance + @Stable to ensure that recompositions can enable skipping optimizations |
46 |
| -// Modifier.composed will still remember unique data per call site. |
47 |
| -private val recomposeModifier = |
48 |
| - Modifier.composed(inspectorInfo = debugInspectorInfo { name = "recomposeHighlighter" }) { |
49 |
| - // The total number of compositions that have occurred. We're not using a State<> here be |
50 |
| - // able to read/write the value without invalidating (which would cause infinite |
51 |
| - // recomposition). |
52 |
| - val totalCompositions = remember { arrayOf(0L) } |
53 |
| - totalCompositions[0]++ |
54 |
| - |
55 |
| - // The value of totalCompositions at the last timeout. |
56 |
| - val totalCompositionsAtLastTimeout = remember { mutableStateOf(0L) } |
57 |
| - |
58 |
| - // Start the timeout, and reset everytime there's a recomposition. (Using totalCompositions |
59 |
| - // as the key is really just to cause the timer to restart every composition). |
60 |
| - LaunchedEffect(totalCompositions[0]) { |
| 46 | +fun Modifier.recomposeHighlighter(): Modifier = this.then(RecomposeHighlighterElement()) |
| 47 | + |
| 48 | +private class RecomposeHighlighterElement : ModifierNodeElement<RecomposeHighlighterModifier>() { |
| 49 | + |
| 50 | + override fun InspectorInfo.inspectableProperties() { |
| 51 | + debugInspectorInfo { name = "recomposeHighlighter" } |
| 52 | + } |
| 53 | + |
| 54 | + override fun create(): RecomposeHighlighterModifier = RecomposeHighlighterModifier() |
| 55 | + |
| 56 | + override fun update(node: RecomposeHighlighterModifier) { |
| 57 | + node.incrementCompositions() |
| 58 | + } |
| 59 | + |
| 60 | + // It's never equal, so that every recomposition triggers the update function. |
| 61 | + override fun equals(other: Any?): Boolean = false |
| 62 | + |
| 63 | + override fun hashCode(): Int = Objects.hash(this) |
| 64 | +} |
| 65 | + |
| 66 | +private class RecomposeHighlighterModifier : Modifier.Node(), DrawModifierNode { |
| 67 | + |
| 68 | + private var timerJob: Job? = null |
| 69 | + |
| 70 | + /** |
| 71 | + * The total number of compositions that have occurred. |
| 72 | + */ |
| 73 | + private var totalCompositions: Long = 0 |
| 74 | + set(value) { |
| 75 | + if (field == value) return |
| 76 | + restartTimer() |
| 77 | + field = value |
| 78 | + invalidateDraw() |
| 79 | + } |
| 80 | + |
| 81 | + fun incrementCompositions() { |
| 82 | + totalCompositions++ |
| 83 | + } |
| 84 | + |
| 85 | + override fun onAttach() { |
| 86 | + super.onAttach() |
| 87 | + restartTimer() |
| 88 | + } |
| 89 | + |
| 90 | + override val shouldAutoInvalidate: Boolean = false |
| 91 | + |
| 92 | + override fun onDetach() { |
| 93 | + timerJob?.cancel() |
| 94 | + } |
| 95 | + |
| 96 | + /** |
| 97 | + * Start the timeout, and reset everytime there's a recomposition. |
| 98 | + */ |
| 99 | + private fun restartTimer() { |
| 100 | + if (!isAttached) return |
| 101 | + |
| 102 | + timerJob?.cancel() |
| 103 | + timerJob = coroutineScope.launch { |
61 | 104 | delay(3000)
|
62 |
| - totalCompositionsAtLastTimeout.value = totalCompositions[0] |
| 105 | + totalCompositions = 0 |
| 106 | + invalidateDraw() |
63 | 107 | }
|
| 108 | + } |
64 | 109 |
|
65 |
| - Modifier.drawWithCache { |
66 |
| - onDrawWithContent { |
67 |
| - // Draw actual content. |
68 |
| - drawContent() |
| 110 | + override fun ContentDrawScope.draw() { |
| 111 | + // Draw actual content. |
| 112 | + drawContent() |
69 | 113 |
|
70 |
| - // Below is to draw the highlight, if necessary. A lot of the logic is copied from |
71 |
| - // Modifier.border |
72 |
| - val numCompositionsSinceTimeout = |
73 |
| - totalCompositions[0] - totalCompositionsAtLastTimeout.value |
| 114 | + // Below is to draw the highlight, if necessary. A lot of the logic is copied from Modifier.border |
74 | 115 |
|
75 |
| - val hasValidBorderParams = size.minDimension > 0f |
76 |
| - if (!hasValidBorderParams || numCompositionsSinceTimeout <= 0) { |
77 |
| - return@onDrawWithContent |
78 |
| - } |
| 116 | + val hasValidBorderParams = size.minDimension > 0f |
| 117 | + if (!hasValidBorderParams || totalCompositions <= 0) { |
| 118 | + return |
| 119 | + } |
79 | 120 |
|
80 |
| - val (color, strokeWidthPx) = |
81 |
| - when (numCompositionsSinceTimeout) { |
82 |
| - // We need at least one composition to draw, so draw the smallest border |
83 |
| - // color in blue. |
84 |
| - 1L -> Color.Blue to 1f |
85 |
| - // 2 compositions is _probably_ okay. |
86 |
| - 2L -> Color.Green to 2.dp.toPx() |
87 |
| - // 3 or more compositions before timeout may indicate an issue. lerp the |
88 |
| - // color from yellow to red, and continually increase the border size. |
89 |
| - else -> { |
90 |
| - lerp( |
91 |
| - Color.Yellow.copy(alpha = 0.8f), |
92 |
| - Color.Red.copy(alpha = 0.5f), |
93 |
| - min(1f, (numCompositionsSinceTimeout - 1).toFloat() / 100f) |
94 |
| - ) to numCompositionsSinceTimeout.toInt().dp.toPx() |
95 |
| - } |
96 |
| - } |
97 |
| - |
98 |
| - val halfStroke = strokeWidthPx / 2 |
99 |
| - val topLeft = Offset(halfStroke, halfStroke) |
100 |
| - val borderSize = Size(size.width - strokeWidthPx, size.height - strokeWidthPx) |
101 |
| - |
102 |
| - val fillArea = (strokeWidthPx * 2) > size.minDimension |
103 |
| - val rectTopLeft = if (fillArea) Offset.Zero else topLeft |
104 |
| - val size = if (fillArea) size else borderSize |
105 |
| - val style = if (fillArea) Fill else Stroke(strokeWidthPx) |
106 |
| - |
107 |
| - drawRect( |
108 |
| - brush = SolidColor(color), |
109 |
| - topLeft = rectTopLeft, |
110 |
| - size = size, |
111 |
| - style = style |
112 |
| - ) |
| 121 | + val (color, strokeWidthPx) = |
| 122 | + when (totalCompositions) { |
| 123 | + // We need at least one composition to draw, so draw the smallest border |
| 124 | + // color in blue. |
| 125 | + 1L -> Color.Blue to 1f |
| 126 | + // 2 compositions is _probably_ okay. |
| 127 | + 2L -> Color.Green to 2.dp.toPx() |
| 128 | + // 3 or more compositions before timeout may indicate an issue. lerp the |
| 129 | + // color from yellow to red, and continually increase the border size. |
| 130 | + else -> { |
| 131 | + lerp( |
| 132 | + Color.Yellow.copy(alpha = 0.8f), |
| 133 | + Color.Red.copy(alpha = 0.5f), |
| 134 | + min(1f, (totalCompositions - 1).toFloat() / 100f), |
| 135 | + ) to totalCompositions.toInt().dp.toPx() |
| 136 | + } |
113 | 137 | }
|
114 |
| - } |
| 138 | + |
| 139 | + val halfStroke = strokeWidthPx / 2 |
| 140 | + val topLeft = Offset(halfStroke, halfStroke) |
| 141 | + val borderSize = Size(size.width - strokeWidthPx, size.height - strokeWidthPx) |
| 142 | + |
| 143 | + val fillArea = (strokeWidthPx * 2) > size.minDimension |
| 144 | + val rectTopLeft = if (fillArea) Offset.Zero else topLeft |
| 145 | + val size = if (fillArea) size else borderSize |
| 146 | + val style = if (fillArea) Fill else Stroke(strokeWidthPx) |
| 147 | + |
| 148 | + drawRect( |
| 149 | + brush = SolidColor(color), |
| 150 | + topLeft = rectTopLeft, |
| 151 | + size = size, |
| 152 | + style = style, |
| 153 | + ) |
115 | 154 | }
|
| 155 | +} |
0 commit comments