Skip to content

Commit 0d7ac09

Browse files
authored
Migrate recomposeHighlighter to Modifier.Node (#197)
* Migrate recomposeHighlighter to Modifier.Node * Apply Spotless * Simplify recompose highlighter * Apply Spotless --------- Co-authored-by: mlykotom <[email protected]>
1 parent 0236012 commit 0d7ac09

File tree

2 files changed

+112
-72
lines changed

2 files changed

+112
-72
lines changed

compose/recomposehighlighter/src/main/java/com/example/android/compose/recomposehighlighter/MainActivity.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import androidx.compose.material.Button
2626
import androidx.compose.material.Text
2727
import androidx.compose.runtime.Composable
2828
import androidx.compose.runtime.getValue
29-
import androidx.compose.runtime.mutableStateOf
29+
import androidx.compose.runtime.mutableIntStateOf
3030
import androidx.compose.runtime.remember
3131
import androidx.compose.runtime.setValue
3232
import androidx.compose.ui.Modifier
@@ -43,7 +43,7 @@ class MainActivity : ComponentActivity() {
4343

4444
@Composable
4545
private fun Content() {
46-
var counter by remember { mutableStateOf(0) }
46+
var counter by remember { mutableIntStateOf(0) }
4747
Column(
4848
Modifier
4949
.fillMaxSize()

compose/recomposehighlighter/src/main/java/com/example/android/compose/recomposehighlighter/RecomposeHighlighter.kt

+110-70
Original file line numberDiff line numberDiff line change
@@ -16,100 +16,140 @@
1616

1717
package com.example.android.compose.recomposehighlighter
1818

19-
import androidx.compose.runtime.LaunchedEffect
2019
import androidx.compose.runtime.Stable
21-
import androidx.compose.runtime.mutableStateOf
22-
import androidx.compose.runtime.remember
2320
import androidx.compose.ui.Modifier
24-
import androidx.compose.ui.composed
25-
import androidx.compose.ui.draw.drawWithCache
2621
import androidx.compose.ui.geometry.Offset
2722
import androidx.compose.ui.geometry.Size
2823
import androidx.compose.ui.graphics.Color
2924
import androidx.compose.ui.graphics.SolidColor
25+
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
3026
import androidx.compose.ui.graphics.drawscope.Fill
3127
import androidx.compose.ui.graphics.drawscope.Stroke
3228
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
3333
import androidx.compose.ui.platform.debugInspectorInfo
3434
import androidx.compose.ui.unit.dp
35+
import java.util.Objects
3536
import kotlin.math.min
37+
import kotlinx.coroutines.Job
3638
import kotlinx.coroutines.delay
39+
import kotlinx.coroutines.launch
3740

3841
/**
3942
* A [Modifier] that draws a border around elements that are recomposing. The border increases in
4043
* size and interpolates from red to green as more recompositions occur before a timeout.
4144
*/
4245
@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 {
61104
delay(3000)
62-
totalCompositionsAtLastTimeout.value = totalCompositions[0]
105+
totalCompositions = 0
106+
invalidateDraw()
63107
}
108+
}
64109

65-
Modifier.drawWithCache {
66-
onDrawWithContent {
67-
// Draw actual content.
68-
drawContent()
110+
override fun ContentDrawScope.draw() {
111+
// Draw actual content.
112+
drawContent()
69113

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
74115

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+
}
79120

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+
}
113137
}
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+
)
115154
}
155+
}

0 commit comments

Comments
 (0)