Custom EffectsΒΆ
This guide explains how to implement custom visual effects that work with Haze's architecture.
OverviewΒΆ
Haze is designed to be extensible. While Haze ships with a blur effect, you can implement custom effects by implementing the VisualEffect interface and providing a builder extension function.
For a complete reference implementation, see the haze-blur module.
Implementing a VisualEffectΒΆ
Create a class that implements the VisualEffect interface:
import android.graphics.Canvas
import dev.chrisbanes.haze.VisualEffect
import dev.chrisbanes.haze.VisualEffectContext
class CustomVisualEffect : VisualEffect {
// Your effect's configuration properties
var intensity: Float = 1f
var color: Color = Color.Black
override fun attach(context: VisualEffectContext) {
// Called when the effect is first attached
// Initialize any platform-specific resources here
}
override fun update(context: VisualEffectContext) {
// Called when the effect should update its state from composition locals
// or other sources. You can safely read snapshot state here.
}
override fun detach() {
// Called when the effect is removed
// Clean up any resources allocated in attach()
}
override fun DrawScope.draw(context: VisualEffectContext) {
// Called to render the effect
// Draw the effect using the DrawScope receiver
}
}
Interface MethodsΒΆ
attach(context: VisualEffectContext)ΒΆ
Called once when the effect is first attached to a composable. Use this to: - Allocate platform-specific resources (Shaders, RenderEffects, etc.) - Initialize expensive objects - Query platform capabilities
override fun attach(context: VisualEffectContext) {
// Example: Initialize a platform-specific shader
myShader = createShader(context.size, context.requireDensity())
}
update(context: VisualEffectContext)ΒΆ
Called whenever the effect should update its state from composition locals or other sources. You can safely read snapshot state in this function. When any snapshot state read in this function is mutated, this function will be re-invoked.
override fun update(context: VisualEffectContext) {
// Example: read a composition local and trigger invalidation if needed
val someLocal = context.currentValueOf(MyCompositionLocal)
if (someLocal != cachedLocal) {
cachedLocal = someLocal
context.invalidateDraw()
}
}
detach()ΒΆ
Called when the effect is removed. Use this to:
- Release native resources
- Cancel coroutines
- Clean up allocations from attach()
override fun detach() {
myShader?.release()
myCoroutineScope.cancel()
}
draw(context: VisualEffectContext)ΒΆ
Called to render the effect. This is where the actual effect rendering happens.
override fun DrawScope.draw(context: VisualEffectContext) {
// Draw the effect using the DrawScope receiver
drawRect(
color = Color.Black.copy(alpha = 0.5f),
size = context.size,
)
}
VisualEffectContextΒΆ
The context provided to effect methods gives you access to:
interface VisualEffectContext {
val position: Offset // Position of the effect node
val size: Size // Size of the effect area
val layerSize: Size // Size of the graphics layer (may differ from size)
val layerOffset: Offset // Graphics layer offset relative to node position
val rootBounds: Rect // Bounds of the root layout coordinates on screen
val inputScale: HazeInputScale // Input scale factor configuration
val windowId: Any? // Identifier for the containing window
val areas: List<HazeArea> // Source areas this effect should process
val state: HazeState? // Associated HazeState (null for foreground blur)
val coroutineScope: CoroutineScope // CoroutineScope tied to the node lifecycle
fun requireDensity(): Density
fun <T> currentValueOf(local: CompositionLocal<T>): T
fun requireGraphicsContext(): GraphicsContext
fun invalidateDraw()
}
HazeEffectScopeΒΆ
The Modifier.hazeEffect { ... } configuration lambda receives HazeEffectScope as its receiver, providing common properties applicable to all effects:
interface HazeEffectScope {
var visualEffect: VisualEffect // The current visual effect implementation
var inputScale: HazeInputScale // Performance optimization via resolution scaling
var drawContentBehind: Boolean // Whether to draw source content behind the effect
var clipToAreasBounds: Boolean? // Whether to clip to total area bounds
var expandLayerBounds: Boolean? // Whether to expand layer bounds for edge consistency
var forceInvalidateOnPreDraw: Boolean // Force invalidation from pre-draw events
var canDrawArea: ((HazeArea) -> Boolean)? // Optional filter to control which areas are drawn
}
Providing a Builder ExtensionΒΆ
To make your effect convenient to use, provide a builder extension function:
fun HazeEffectScope.customEffect(
block: CustomVisualEffect.() -> Unit = {}
): CustomVisualEffect {
val effect = CustomVisualEffect()
effect.apply(block)
visualEffect = effect
return effect
}
Users can then configure your effect like this:
modifier = Modifier.hazeEffect(state = hazeState) {
customEffect {
intensity = 0.8f
color = Color.Blue
}
}
Platform-Specific ImplementationsΒΆ
Effects often need platform-specific code. Use expect/actual to provide platform implementations:
commonMain:
expect fun createCustomShader(size: Size): Shader
class CustomVisualEffect : VisualEffect {
private lateinit var shader: Shader
override fun attach(context: VisualEffectContext) {
shader = createCustomShader(context.size)
}
}
androidMain:
actual fun createCustomShader(size: Size): Shader {
// Android-specific shader creation
}
desktopMain:
actual fun createCustomShader(size: Size): Shader {
// Skiko-based shader creation
}
See the haze-blur module for complete platform-specific examples.
Publishing Your Effect ModuleΒΆ
To share your custom effect, publish it as a library:
- Create a new Gradle module (e.g.,
haze-myeffect) - Add
hazeas a dependency:dependencies { api("dev.chrisbanes.haze:haze:2.0.0") } - Publish to Maven Central or your preferred repository
Users can then add it like any effect:
dependencies {
implementation("dev.chrisbanes.haze:haze-myeffect:1.0.0")
}
Example: Simple Tint EffectΒΆ
Here's a minimal example of a custom effect that applies a color tint:
class TintVisualEffect : VisualEffect {
var color: Color = Color.Black
var alpha: Float = 0.2f
override fun attach(context: VisualEffectContext) {
// No resources to allocate for a simple tint
}
override fun update(context: VisualEffectContext) {
// Nothing to update from composition locals
}
override fun detach() {
// Nothing to clean up
}
override fun DrawScope.draw(context: VisualEffectContext) {
drawRect(
color = color.copy(alpha = alpha),
size = context.size,
)
}
}
fun HazeEffectScope.tintEffect(
block: TintVisualEffect.() -> Unit = {}
) {
val effect = TintVisualEffect()
effect.apply(block)
visualEffect = effect
}
Usage:
modifier = Modifier.hazeEffect(state = hazeState) {
tintEffect {
color = Color.Blue
alpha = 0.3f
}
}
Best PracticesΒΆ
- Resource Management - Always allocate resources in
attach()and clean up indetach() - Thread Safety - Effects may be accessed from multiple threads; use appropriate synchronization
- Performance - Remember
draw()is called frequently; optimize for performance - Input Scale - Respect
inputScalefromHazeEffectScopeto support performance optimizations - Documentation - Document your effect's configuration properties clearly
- Testing - Use screenshot tests to verify your effect works correctly
Next StepsΒΆ
- See Architecture for an overview of Haze's design
- Review the haze-blur module for a production implementation
- Check out the sample code for usage examples