| name | haze-custom-effects |
| description | Expert guidance on building custom VisualEffect implementations for Haze. Use when creating custom shader effects, non-blur visual effects, or extending Haze with new effect types. |
Haze Custom Effects — Building VisualEffect Implementations
Instructions
Haze is extensible via the VisualEffect interface. The blur effect itself is built on this same interface.
1. The VisualEffect Interface
interface VisualEffect {
fun attach(context: VisualEffectContext)
fun update(context: VisualEffectContext)
fun detach(context: VisualEffectContext)
fun DrawScope.draw(context: VisualEffectContext)
}
| Method | When Called | Purpose |
|---|
attach | Effect first attached to composable | Allocate resources (shaders, caches) |
update | State changes, composition locals change | Read snapshot state, call invalidateDraw() |
detach | Effect removed from composable | Release resources from attach() |
draw | Each render frame | Render the effect (keep allocation-light) |
2. VisualEffectContext
Available to all lifecycle methods:
interface VisualEffectContext {
val position: Offset
val size: Size
val layerSize: Size
val layerOffset: Offset
val rootBounds: Rect
val inputScale: HazeInputScale
val windowId: Any?
val areas: List<HazeArea>
val state: HazeState?
val coroutineScope: CoroutineScope
fun requireDensity(): Density
fun <T> currentValueOf(local: CompositionLocal<T>): T
fun requireGraphicsContext(): GraphicsContext
fun invalidateDraw()
}
Background mode: state != null. Foreground mode: state == null.
3. Example: Simple Custom Effect
@OptIn(ExperimentalHazeApi::class)
class SparkVisualEffect : VisualEffect {
var color: Color = Color.Black
var alpha: Float = 0.2f
override fun attach(context: VisualEffectContext) {
}
override fun update(context: VisualEffectContext) {
val newColor = context.currentValueOf(LocalSparkColor)
if (newColor != color) {
color = newColor
context.invalidateDraw()
}
}
override fun detach(context: VisualEffectContext) {
}
override fun DrawScope.draw(context: VisualEffectContext) {
drawRect(
color = color.copy(alpha = alpha),
size = context.size,
)
}
}
4. Builder Extension Pattern
Expose your effect as a HazeEffectScope extension:
@OptIn(ExperimentalHazeApi::class)
fun HazeEffectScope.sparkEffect(
block: SparkVisualEffect.() -> Unit,
) {
val effect = visualEffect as? SparkVisualEffect ?: SparkVisualEffect()
visualEffect = effect
effect.block()
}
Usage:
Modifier.hazeEffect(state = hazeState) {
sparkEffect {
color = Color.Blue
alpha = 0.3f
}
}
5. Background Source Layers (Primary Pattern)
Sample transformed source layers that your effect processes:
val hazeState = rememberHazeState()
Box(Modifier.fillMaxSize()) {
AsyncImage(
modifier = Modifier
.fillMaxSize()
.graphicsLayer { scaleX = 1.06f; translationX = 24f }
.hazeSource(state = hazeState),
model = "...", contentDescription = null
)
Box(
modifier = Modifier
.size(260.dp, 180.dp)
.graphicsLayer { rotationZ = 10f }
.hazeSource(state = hazeState, zIndex = 1f),
)
Box(
modifier = Modifier
.align(Alignment.Center)
.hazeEffect(state = hazeState) {
sparkEffect { }
},
)
}
Haze samples the transformed hazeSource layers behind the target node. Your draw() method controls how that sampled content is rendered.
6. Platform-Specific Implementations
Use expect/actual for platform-specific rendering:
expect fun createPlatformShader(size: Size): Shader
@OptIn(ExperimentalHazeApi::class)
class ShaderEffect : VisualEffect {
private lateinit var shader: Shader
override fun attach(context: VisualEffectContext) {
shader = createPlatformShader(context.size)
}
}
actual fun createPlatformShader(size: Size): Shader {
}
actual fun createPlatformShader(size: Size): Shader {
}
7. Layer Bounds Override
If your effect needs extra sampling space outside the node bounds:
class MyEffect : VisualEffect {
override fun calculateLayerBounds(rect: Rect, density: Density): Rect {
val extra = with(density) { 24.dp.toPx() }
return rect.inflate(extra)
}
}
Coordinate space: same as input rect. Background mode = root/screen-aligned. Foreground mode = local node rect.
8. Ownership Model
VisualEffect instances are single-owner:
- One effect instance → one active
hazeEffect node
- Don't share the same instance across multiple active nodes
- Create/reuse per node via your builder pattern
9. Best Practices
- Allocate in
attach, release in detach
- Keep
draw allocation-free — hot path, called every frame
- Use
update for tracked state reads and controlled invalidation via context.invalidateDraw()
- Respect
inputScale and bounds contracts for performance and correctness
- Validate with screenshot tests to catch visual regressions
- Geometry in
attach: position, size, layerSize, layerOffset may be zero/unresolved at attach time
10. Checklist