Skip to content

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:

  1. Create a new Gradle module (e.g., haze-myeffect)
  2. Add haze as a dependency:
    dependencies {
        api("dev.chrisbanes.haze:haze:2.0.0")
    }
    
  3. 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ΒΆ

  1. Resource Management - Always allocate resources in attach() and clean up in detach()
  2. Thread Safety - Effects may be accessed from multiple threads; use appropriate synchronization
  3. Performance - Remember draw() is called frequently; optimize for performance
  4. Input Scale - Respect inputScale from HazeEffectScope to support performance optimizations
  5. Documentation - Document your effect's configuration properties clearly
  6. Testing - Use screenshot tests to verify your effect works correctly

Next StepsΒΆ