Skip to content

Commit aeb72da

Browse files
authored
Add workaround for invalidations not happening on Skia backed platforms (#296)
We need to re-draw children (`Modifier.hazeChild`) when the background content `Modifier.haze`) has updated the `contentLayer`. That works automatically on Android, as it uses `RenderNodes` which automatically repaint when an upstream `RenderNode` is updated. The Skia backed platforms do not do this though, so we can't rely on this behavior. This PR adds in a state backed `invalidationTick`, allowing the `hazeChild` on Skia-backed platforms to know when the content layer has been updated. Why don't we just the `contentLayer` in a state I hear you ask? GraphicsLayers aren't state. They don't implmenent equality, so every draw will trigger readers that the value has changed. This means that we can easily get into invalidation loops: 1. Parent draws, creates a new GraphicsLayer and updates state. 2. Children are notified that the GL has changed. They trigger a draw invalidation. 3. Parent is drawn, creates a new GraphicsLayer and updates state. 4. Repeat. The invalidationTick added here would act exactly the same, but because it's an `Int` we can use it to know when NOT to invalidate in step 2. This is the very giant hack in this MR: we only invalidate every other invalidation tick change.
1 parent 2a5e9de commit aeb72da

File tree

13 files changed

+134
-24
lines changed

13 files changed

+134
-24
lines changed

gradle/build-logic/convention/src/main/kotlin/dev/chrisbanes/gradle/Kotlin.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,16 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
99
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmCompilerOptions
1010
import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask
1111

12-
fun Project.configureKotlin() {
12+
fun Project.configureKotlin(enableAllWarningsAsErrors: Boolean = false) {
1313
// Configure Java to use our chosen language level. Kotlin will automatically pick this up
1414
configureJava()
1515

1616
tasks.withType<KotlinCompilationTask<*>>().configureEach {
1717
compilerOptions {
18-
allWarningsAsErrors.set(true)
18+
// Blocked by https://youtrack.jetbrains.com/issue/KT-69701/
19+
if (enableAllWarningsAsErrors) {
20+
allWarningsAsErrors.set(true)
21+
}
1922

2023
if (this is KotlinJvmCompilerOptions) {
2124
// Target JVM 11 bytecode
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Copyright 2024, Christopher Banes and the Haze project contributors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package dev.chrisbanes.haze
5+
6+
internal actual fun HazeEffectNode.observeInvalidationTick() {
7+
// No need to do anything on Android
8+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Copyright 2024, Christopher Banes and the Haze project contributors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package dev.chrisbanes.haze
5+
6+
import android.util.Log
7+
8+
internal actual fun log(tag: String, message: () -> String) {
9+
if (LOG_ENABLED && Log.isLoggable(tag, Log.DEBUG)) {
10+
Log.d(tag, message())
11+
}
12+
}

haze/src/commonMain/kotlin/dev/chrisbanes/haze/Haze.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package dev.chrisbanes.haze
66
import androidx.compose.runtime.Immutable
77
import androidx.compose.runtime.Stable
88
import androidx.compose.runtime.getValue
9+
import androidx.compose.runtime.mutableIntStateOf
910
import androidx.compose.runtime.mutableStateOf
1011
import androidx.compose.runtime.setValue
1112
import androidx.compose.ui.Modifier
@@ -36,6 +37,8 @@ class HazeState {
3637
*/
3738
var contentLayer: GraphicsLayer? = null
3839
internal set
40+
41+
internal var invalidateTick by mutableIntStateOf(Int.MIN_VALUE)
3942
}
4043

4144
@Stable

haze/src/commonMain/kotlin/dev/chrisbanes/haze/HazeChild.kt

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,8 @@ import androidx.compose.ui.graphics.Shape
1111
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
1212
import androidx.compose.ui.layout.LayoutCoordinates
1313
import androidx.compose.ui.node.ModifierNodeElement
14-
import androidx.compose.ui.node.invalidateDraw
1514
import androidx.compose.ui.platform.InspectorInfo
1615
import androidx.compose.ui.unit.toSize
17-
import kotlinx.coroutines.launch
1816

1917
/**
2018
* Mark this composable as being a Haze child composable.
@@ -46,9 +44,6 @@ fun Modifier.hazeChild(
4644
* This will update the given [HazeState] whenever the layout is placed, enabling any layouts using
4745
* [Modifier.haze] to blur any content behind the host composable.
4846
*
49-
* @param shape The shape of the content. This will affect the the bounds and outline of
50-
* the content. Please be aware that using non-rectangular shapes has an effect on performance,
51-
* since we need to use path clipping.
5247
* @param style The [HazeStyle] to use on this content. Any specified values in the given
5348
* style will override that value from the default style, provided to [haze].
5449
* @param mask An optional mask which allows effects such as fading via [Brush.verticalGradient] or similar.
@@ -90,8 +85,6 @@ private class HazeChildNode(
9085
HazeArea(style = style, mask = mask)
9186
}
9287

93-
private var drawWithoutContentLayerCount = 0
94-
9588
override fun update() {
9689
area.style = style
9790
area.mask = mask
@@ -112,9 +105,12 @@ private class HazeChildNode(
112105
}
113106

114107
override fun ContentDrawScope.draw() {
108+
log(TAG) { "-> HazeChild. start draw()" }
109+
115110
if (effects.isEmpty()) {
116111
// If we don't have any effects, just call drawContent and return early
117112
drawContent()
113+
log(TAG) { "-> HazeChild. end draw()" }
118114
return
119115
}
120116

@@ -126,23 +122,21 @@ private class HazeChildNode(
126122
if (USE_GRAPHICS_LAYERS) {
127123
val contentLayer = state.contentLayer
128124
if (contentLayer != null) {
129-
drawWithoutContentLayerCount = 0
130125
drawEffectsWithGraphicsLayer(contentLayer)
131-
} else {
132-
// The content layer has not have been drawn yet (draw order matters here). If it hasn't
133-
// there's not much we do other than invalidate and wait for the next frame.
134-
// We only want to force a few frames, otherwise we're causing a draw loop.
135-
if (++drawWithoutContentLayerCount <= 2) {
136-
coroutineScope.launch { invalidateDraw() }
137-
}
138126
}
139127
} else {
140128
drawEffectsWithScrim()
141129
}
142130

143131
// Finally we draw the content
144132
drawContent()
133+
134+
log(TAG) { "-> HazeChild. end draw()" }
145135
}
146136

147137
override fun calculateHazeAreas(): Sequence<HazeArea> = sequenceOf(area)
138+
139+
private companion object {
140+
const val TAG = "HazeChild"
141+
}
148142
}

haze/src/commonMain/kotlin/dev/chrisbanes/haze/HazeEffect.kt

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ internal abstract class HazeEffectNode :
5555

5656
override val shouldAutoInvalidate: Boolean = false
5757

58+
internal var lastInvalidationTick = Int.MIN_VALUE
59+
5860
open fun update() {
5961
onObservedReadsChanged()
6062
}
@@ -65,9 +67,8 @@ internal abstract class HazeEffectNode :
6567

6668
override fun onObservedReadsChanged() {
6769
observeReads {
68-
if (updateEffects()) {
69-
invalidateDraw()
70-
}
70+
updateEffects()
71+
observeInvalidationTick()
7172
}
7273
}
7374

@@ -77,7 +78,7 @@ internal abstract class HazeEffectNode :
7778

7879
override fun onGloballyPositioned(coordinates: LayoutCoordinates) = onPlaced(coordinates)
7980

80-
protected open fun updateEffects(): Boolean {
81+
protected open fun updateEffects() {
8182
val currentEffectsIsEmpty = effects.isEmpty()
8283
val currentEffects = effects.associateByTo(mutableMapOf(), HazeEffect::area)
8384

@@ -118,7 +119,9 @@ internal abstract class HazeEffectNode :
118119

119120
// Invalidate if any of the effects triggered an invalidation, or we now have zero
120121
// effects but were previously showing some
121-
return needInvalidate || (effects.isEmpty() != currentEffectsIsEmpty)
122+
if (needInvalidate || (effects.isEmpty() != currentEffectsIsEmpty)) {
123+
invalidateDraw()
124+
}
122125
}
123126

124127
protected fun DrawScope.drawEffectsWithGraphicsLayer(contentLayer: GraphicsLayer) {
@@ -205,6 +208,8 @@ internal expect fun HazeEffectNode.createRenderEffect(
205208
density: Density,
206209
): RenderEffect?
207210

211+
internal expect fun HazeEffectNode.observeInvalidationTick()
212+
208213
internal class HazeEffect(val area: HazeArea) {
209214
var renderEffect: RenderEffect? = null
210215
var renderEffectDirty: Boolean = true

haze/src/commonMain/kotlin/dev/chrisbanes/haze/HazeNode.kt

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ internal class HazeNode(
5151
override val shouldAutoInvalidate: Boolean = false
5252

5353
override fun ContentDrawScope.draw() {
54+
log(TAG) { "start draw()" }
55+
5456
if (!USE_GRAPHICS_LAYERS) {
5557
// If we're not using graphics layers, just call drawContent and return early
5658
drawContent()
@@ -59,8 +61,9 @@ internal class HazeNode(
5961

6062
val graphicsContext = currentValueOf(LocalGraphicsContext)
6163

62-
val contentLayer = state.contentLayer ?: graphicsContext.createGraphicsLayer()
63-
state.contentLayer = contentLayer
64+
val contentLayer = state.contentLayer
65+
?.takeUnless { it.isReleased }
66+
?: graphicsContext.createGraphicsLayer().also { state.contentLayer = it }
6467

6568
// First we draw the composable content into a graphics layer
6669
contentLayer.record {
@@ -69,6 +72,11 @@ internal class HazeNode(
6972

7073
// Now we draw `content` into the window canvas
7174
drawLayer(contentLayer)
75+
76+
val tick = Snapshot.withoutReadObservation { state.invalidateTick }
77+
state.invalidateTick = tick + 1
78+
79+
log(TAG) { "end draw()" }
7280
}
7381

7482
override fun onDetach() {
@@ -79,6 +87,10 @@ internal class HazeNode(
7987
}
8088
state.contentLayer = null
8189
}
90+
91+
private companion object {
92+
const val TAG = "HazeNode"
93+
}
8294
}
8395

8496
internal expect val USE_GRAPHICS_LAYERS: Boolean
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Copyright 2024, Christopher Banes and the Haze project contributors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package dev.chrisbanes.haze
5+
6+
internal const val LOG_ENABLED = false
7+
8+
internal expect fun log(tag: String, message: () -> String)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Copyright 2024, Christopher Banes and the Haze project contributors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package dev.chrisbanes.haze
5+
6+
import platform.Foundation.NSLog
7+
8+
internal actual fun log(tag: String, message: () -> String) {
9+
if (LOG_ENABLED) {
10+
NSLog("[%s] %s", tag, message())
11+
}
12+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Copyright 2024, Christopher Banes and the Haze project contributors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package dev.chrisbanes.haze
5+
6+
internal actual fun log(tag: String, message: () -> String) {
7+
if (LOG_ENABLED) {
8+
println("[$tag] ${message()}")
9+
}
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Copyright 2024, Christopher Banes and the Haze project contributors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package dev.chrisbanes.haze
5+
6+
internal actual fun log(tag: String, message: () -> String) {
7+
if (LOG_ENABLED) {
8+
println("[$tag] ${message()}")
9+
}
10+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Copyright 2024, Christopher Banes and the Haze project contributors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package dev.chrisbanes.haze
5+
6+
import androidx.compose.ui.node.invalidateDraw
7+
8+
internal actual fun HazeEffectNode.observeInvalidationTick() {
9+
// Yes, this is very very gross. The HazeNode will update the contentLayer in it's draw
10+
// function. HazeNode and also updates `state.invalidateTick` as a way for HazeChild[ren] to
11+
// know when the contentLayer has been updated. All fine so far, but for us to draw with the
12+
// updated `contentLayer` we need to invalidate. Invalidating ourselves will trigger us to
13+
// draw, but it will also trigger `contentLayer` to invalidate, and here's an infinite
14+
// draw loop we trigger.
15+
//
16+
// This is a huge giant hack, but by skipping every other invalidation caused by a
17+
// `invalidationTick` change, we break the loop.
18+
val tick = state.invalidateTick
19+
if (tick != lastInvalidationTick && tick % 2 == 0) {
20+
invalidateDraw()
21+
}
22+
lastInvalidationTick = tick
23+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Copyright 2024, Christopher Banes and the Haze project contributors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package dev.chrisbanes.haze
5+
6+
internal actual fun log(tag: String, message: () -> String) {
7+
if (LOG_ENABLED) {
8+
println("[$tag] ${message()}")
9+
}
10+
}

0 commit comments

Comments
 (0)