Build Your Own Landscapist Image Plugin in Jetpack Compose

skydovesJaewoong Eum (skydoves)||17 min read

Build Your Own Landscapist Image Plugin in Jetpack Compose

Landscapist provides a composable image loading library for Jetpack Compose and Kotlin Multiplatform. Among its image composables, LandscapistImage stands out as the recommended choice: it uses Landscapist's own standalone loading engine built from scratch for Jetpack Compose and Kotlin Multiplatform, with no dependency on platform specific loaders like Glide or Coil. It handles fetching, caching, decoding, and display internally, and it works identically across Android, iOS, Desktop, and Web. On top of that, LandscapistImage exposes a plugin system through the ImagePlugin sealed interface, giving you five distinct hook points into the image loading lifecycle where you can inject custom behavior without modifying the loader itself.

In this article, you'll explore the ImagePlugin architecture, examining each of the five plugin types and why they exist, how ImagePluginComponent collects and dispatches plugins through a DSL, and how built in plugins like PlaceholderPlugin, ShimmerPlugin, CircularRevealPlugin, PalettePlugin, and ZoomablePlugin implement these interfaces in practice.

Why LandscapistImage for plugins

Before diving into the plugin system, it is worth understanding why LandscapistImage is the best foundation for plugin based image loading.

LandscapistImage uses its own standalone engine (landscapist-core) rather than delegating to Glide, Coil, or Fresco. This means every stage of the image loading pipeline, from network fetching through memory caching to bitmap decoding, is controlled by a single Kotlin Multiplatform implementation. The benefit for plugins is direct: when LandscapistImage transitions from loading to success, it knows the exact moment the bitmap becomes available. It passes that bitmap directly to PainterPlugin and SuccessStatePlugin without any adapter layer or platform specific conversion. The plugin receives a real ImageBitmap, not a wrapped platform object.

This also means LandscapistImage works on every Compose Multiplatform target. A ShimmerPlugin you write for Android runs identically on iOS and Desktop. There is no "this plugin only works with Glide" problem, because there is no Glide in the pipeline.

If you look at the LandscapistImage composable signature, you can see where plugins fit in:

@Composable
public fun LandscapistImage(
  imageModel: () -> Any?,
  modifier: Modifier = Modifier,
  component: ImageComponent = rememberImageComponent {},
  imageOptions: ImageOptions = ImageOptions(),
  loading: @Composable (BoxScope.(LandscapistImageState.Loading) -> Unit)? = null,
  success: @Composable (BoxScope.(LandscapistImageState.Success, Painter) -> Unit)? = null,
  failure: @Composable (BoxScope.(LandscapistImageState.Failure) -> Unit)? = null,
)

The component parameter is the entry point for the plugin system. When you pass a rememberImageComponent { ... } block, every plugin you add inside that block gets dispatched at the correct lifecycle stage automatically. You can still use loading, success, and failure lambdas for one off customization, but plugins are the reusable, composable alternative.

The fundamental problem: Extending image loading without modifying it

Consider a common scenario: you are loading images with LandscapistImage and want to show a shimmer effect during loading, apply a circular reveal animation on success, and extract the dominant color from the loaded image. Without a plugin system, you would need to manage all of this manually:

LandscapistImage(
  imageModel = { imageUrl },
  loading = {
    // manually build shimmer UI
  },
  success = { imageState, painter ->
    // manually animate circular reveal
    // manually extract palette colors
  },
  failure = {
    // manually show error placeholder
  },
)

This approach has two problems. First, each behavior is tightly coupled to the call site. If you want the same shimmer in ten different screens, you duplicate the logic ten times. If you want to add a new behavior, you touch every call site. Second, behaviors that operate at different conceptual layers (UI rendering, painter transformation, side effects) are mixed together in the same lambda, making it harder to reason about what happens when.

The plugin system solves both problems. It turns each behavior into a self contained, reusable component that declares which lifecycle stage it belongs to. You compose them together declaratively, and the dispatch mechanism handles the rest.

Introducing ImagePlugin

The ImagePlugin system is designed around one principle: adding or removing image loading behavior should be as simple as adding or removing a single line of code. In practice, that means attaching a plugin with the + operator and detaching it by deleting that line:

LandscapistImage(
  imageModel = { imageUrl },
  component = rememberImageComponent {
    +ShimmerPlugin()              // attach shimmer during loading
    +CircularRevealPlugin()       // attach reveal animation on success
    +PalettePlugin { palette -> } // attach color extraction
  },
)

If the shimmer is no longer needed, you remove the +ShimmerPlugin() line. The rest of the plugins continue to work without any changes. There is no cleanup code, no conditional branches to update, and no shared state to untangle. Each plugin is self contained: it carries its own configuration, renders its own UI or performs its own side effect, and has no dependency on other plugins in the list.

This makes the plugin system useful beyond pre built options. When your team needs custom behavior tied to the image loading lifecycle, whether that is logging analytics when an image loads, showing a domain specific error screen on failure, or applying a brand specific visual transformation, anyone on the team can implement a custom ImagePlugin, attach it with +, and remove it later if the requirement changes. The plugin boundary keeps custom behavior isolated from the image loading pipeline, so adding or removing a plugin never risks breaking the loader itself.

The plugin system supports five types of plugins, each targeting a different moment in the image loading lifecycle. Let's examine the interface that defines them.

The ImagePlugin sealed interface

The foundation of the plugin system is a sealed interface with five subtypes. Each subtype corresponds to a specific moment in the image loading lifecycle and receives parameters appropriate for that moment. If you examine the ImagePlugin interface:

@Immutable
public sealed interface ImagePlugin {

  public interface PainterPlugin : ImagePlugin {
    @Composable
    public fun compose(imageBitmap: ImageBitmap, painter: Painter): Painter
  }

  public interface LoadingStatePlugin : ImagePlugin {
    @Composable
    public fun compose(
      modifier: Modifier,
      imageOptions: ImageOptions,
      executor: @Composable (IntSize) -> Unit,
    ): ImagePlugin
  }

The remaining three types follow the same pattern:

  public interface SuccessStatePlugin : ImagePlugin {
    @Composable
    public fun compose(
      modifier: Modifier,
      imageModel: Any?,
      imageOptions: ImageOptions,
      imageBitmap: ImageBitmap?,
    ): ImagePlugin
  }

  public interface FailureStatePlugin : ImagePlugin {
    @Composable
    public fun compose(
      modifier: Modifier,
      imageOptions: ImageOptions,
      reason: Throwable?,
    ): ImagePlugin
  }

  public interface ComposablePlugin : ImagePlugin {
    @Composable
    public fun compose(content: @Composable () -> Unit)
  }
}

The reason for five separate types rather than a single onStateChanged callback is that each lifecycle stage has fundamentally different needs.

PainterPlugin receives the loaded ImageBitmap and the current Painter, and returns a new Painter. This is where you transform the visual output itself. The return type is important: because it takes a Painter in and returns a Painter out, multiple painter plugins can be chained. A circular reveal wraps the original painter, then a blur wraps the reveal, forming a decoration chain. This works because LandscapistImage provides the decoded ImageBitmap directly from its internal cache, so painter plugins always have access to the raw bitmap data for their transformations.

LoadingStatePlugin receives the Modifier, ImageOptions, and an executor callback. The executor is a composable lambda that LandscapistImage provides for rendering a low resolution thumbnail while the full image loads. Plugins like ThumbnailPlugin use this executor to request a smaller version of the same image from the loading pipeline. Other loading plugins like ShimmerPlugin ignore the executor entirely and render their own UI instead.

SuccessStatePlugin receives the loaded imageBitmap along with the original imageModel. This runs after a successful load, and its primary purpose is side effects rather than UI rendering. The imageModel parameter is included because some side effects, like palette caching, need to associate their results with the original image URL.

FailureStatePlugin receives the Throwable reason for the failure. A custom failure plugin could use this to display different error images based on the failure type, for example showing a network error icon for IOException and a format error icon for IllegalArgumentException.

ComposablePlugin receives the entire image content as a composable lambda. Unlike the other four types, this plugin does not operate on a specific lifecycle state. Instead, it wraps the rendered image content, regardless of which state produced it. This makes it the right choice for behaviors that apply to the image as a whole, like gesture handling, overlays, or accessibility wrappers.

The key observation: the sealed interface ensures every plugin must declare which lifecycle stage it belongs to at compile time. There is no ambiguity about when a plugin runs, and the compiler enforces that you implement the correct compose signature for your chosen stage.

How plugins are collected: ImagePluginComponent

Plugins need a container that collects them and makes them available to the image composable. The ImagePluginComponent class provides a DSL for collecting plugins into an ordered list:

@Stable
public class ImagePluginComponent(
  internal val mutablePlugins: MutableList<ImagePlugin> = mutableListOf(),
) : ImageComponent {

  public val plugins: List<ImagePlugin>
    inline get() = mutablePlugins

  public fun add(imagePlugin: ImagePlugin): ImagePluginComponent = apply {
    mutablePlugins.add(imagePlugin)
  }

  public operator fun ImagePlugin.unaryPlus(): ImagePluginComponent = add(this)
}

The unaryPlus operator is what enables the +Plugin() syntax. When you write +ShimmerPlugin(), Kotlin invokes the unaryPlus extension function defined inside ImagePluginComponent, which calls add() internally and appends the plugin to the mutable list. Because this is an operator defined in the scope of the component, it only works inside the ImagePluginComponent receiver block, preventing accidental use outside of plugin configuration.

The rememberImageComponent function creates and remembers this component across recompositions:

@Composable
public fun rememberImageComponent(
  block: @Composable ImagePluginComponent.() -> Unit,
): ImagePluginComponent {
  val imageComponent = imageComponent(block)
  return remember { imageComponent }
}

The remember call ensures the plugin list is built once and cached. This matters for performance: without it, every recomposition would recreate the plugin list and all plugin instances. You use it like this:

LandscapistImage(
  imageModel = { imageUrl },
  component = rememberImageComponent {
    +ShimmerPlugin()
    +CircularRevealPlugin(duration = 500)
    +PalettePlugin(paletteLoadedListener = { palette -> })
  },
)

Three plugins, three different lifecycle stages, all composed into a single component with a clean DSL. The order in the block determines the order of execution within each plugin type.

Built in plugins: From simple to advanced

Now let's examine how the built in plugins implement these interfaces. Each example demonstrates a different pattern for how plugins interact with LandscapistImage's loading pipeline.

PlaceholderPlugin: The simplest LoadingStatePlugin

PlaceholderPlugin is the most straightforward implementation. It displays a static image while loading is in progress, using whatever image source you provide. If you examine the PlaceholderPlugin.Loading class:

public data class Loading(val source: Any?) :
  PlaceholderPlugin(),
  ImagePlugin.LoadingStatePlugin {

  @Composable
  override fun compose(
    modifier: Modifier,
    imageOptions: ImageOptions,
    executor: @Composable (IntSize) -> Unit,
  ): ImagePlugin = apply {
    if (source != null) {
      ImageBySource(
        source = source,
        modifier = modifier,
        alignment = imageOptions.alignment,
        contentDescription = imageOptions.contentDescription,
        contentScale = imageOptions.contentScale,
        colorFilter = imageOptions.colorFilter,
        alpha = imageOptions.alpha,
      )
    }
  }
}

The source parameter accepts an ImageBitmap, ImageVector, or Painter. The compose function renders that source using ImageBySource, passing through all the image options from the parent LandscapistImage. This is an important detail: the plugin inherits contentScale, alignment, and other display options automatically, so the placeholder looks consistent with the final image. The apply return pattern means the plugin returns itself, a convention used across all state plugins.

The failure variant follows the same structure but implements FailureStatePlugin instead:

public data class Failure(val source: Any?) :
  PlaceholderPlugin(),
  ImagePlugin.FailureStatePlugin {

  @Composable
  override fun compose(
    modifier: Modifier,
    imageOptions: ImageOptions,
    reason: Throwable?,
  ): ImagePlugin = apply {
    if (source != null) {
      ImageBySource(
        source = source,
        modifier = modifier,
        alignment = imageOptions.alignment,
        contentDescription = imageOptions.contentDescription,
        contentScale = imageOptions.contentScale,
        colorFilter = imageOptions.colorFilter,
        alpha = imageOptions.alpha,
      )
    }
  }
}

Notice how the reason: Throwable? parameter is available but unused here. The built in PlaceholderPlugin.Failure shows the same static image regardless of the error type. A custom failure plugin could inspect this parameter to display different images: a network icon for connection errors, a broken image icon for decode failures, and so on. This is where building your own plugin adds value over the built in options.

ShimmerPlugin: Adding animated loading state

ShimmerPlugin is another LoadingStatePlugin, but instead of a static image, it displays an animated shimmer effect that signals to the user that content is loading:

@Immutable
public data class ShimmerPlugin(
  val shimmer: Shimmer = Shimmer.Flash(
    baseColor = Color.DarkGray,
    highlightColor = Color.LightGray,
  ),
) : ImagePlugin.LoadingStatePlugin {

  @Composable
  override fun compose(
    modifier: Modifier,
    imageOptions: ImageOptions,
    executor: @Composable (IntSize) -> Unit,
  ): ImagePlugin = apply {
    ShimmerContainer(
      modifier = modifier,
      shimmer = shimmer,
    )
  }
}

The structure is identical to PlaceholderPlugin. The only difference is what gets composed. Instead of ImageBySource, it renders a ShimmerContainer with customizable base and highlight colors. This demonstrates how the plugin interface keeps each implementation focused on a single responsibility: PlaceholderPlugin renders a static image, ShimmerPlugin renders an animation, but both hook into the same loading state lifecycle through the same compose signature.

Also notice that ShimmerPlugin ignores the executor parameter entirely. The executor is there for plugins that want to render a low resolution thumbnail while loading (like ThumbnailPlugin), but ShimmerPlugin has no use for it. This is a benefit of the interface design: plugins receive all the context they might need, but they are free to use only what is relevant to their behavior.

CircularRevealPlugin: Transforming the painter

CircularRevealPlugin demonstrates the PainterPlugin type, which operates on a fundamentally different level than state plugins. Instead of composing UI at a lifecycle stage, it transforms the Painter that renders the image:

@Immutable
public data class CircularRevealPlugin(
  public val duration: Int = DefaultCircularRevealDuration,
  public val onFinishListener: CircularRevealFinishListener? = null,
) : ImagePlugin.PainterPlugin {

  @Composable
  override fun compose(imageBitmap: ImageBitmap, painter: Painter): Painter {
    return painter.rememberCircularRevealPainter(
      imageBitmap = imageBitmap,
      durationMs = duration,
      onFinishListener = onFinishListener,
    )
  }
}

The compose function receives the current painter and wraps it with a circular reveal animation painter using rememberCircularRevealPainter. The original painter is passed through as the base, so the animation decorates the existing rendering behavior rather than replacing it. When LandscapistImage first transitions to the success state, the circular reveal painter starts its animation, gradually revealing the image from the center outward.

The BlurTransformationPlugin follows the same pattern:

@Immutable
public data class BlurTransformationPlugin(
  public val radius: Int = 10,
) : ImagePlugin.PainterPlugin {

  @Composable
  override fun compose(imageBitmap: ImageBitmap, painter: Painter): Painter {
    return painter.rememberBlurPainter(
      imageBitmap = imageBitmap,
      radius = radius,
    )
  }
}

Both take a painter in and return a new painter out. Because PainterPlugin forms a chain (as shown in the composePainterPlugins dispatch function earlier), you can stack multiple painter transformations. If you add both CircularRevealPlugin and BlurTransformationPlugin, the blur is applied on top of the circular reveal, which wraps the original painter. Reversing the order would produce a different visual result: the reveal would animate over an already blurred image.

PalettePlugin: Reacting to success with side effects

PalettePlugin implements SuccessStatePlugin, running only after the image loads successfully. Unlike the plugins above, PalettePlugin does not render any UI. Its purpose is to extract dominant colors from the loaded bitmap and expose them to the rest of your composable tree:

@Immutable
public data class PalettePlugin(
  private val imageModel: Any? = null,
  private val useCache: Boolean = true,
  private val interceptor: PaletteBuilderInterceptor? = null,
  private val paletteLoadedListener: PaletteLoadedListener? = null,
) : ImagePlugin.SuccessStatePlugin {

  private val bitmapPalette = BitmapPalette(
    imageModel = imageModel,
    useCache = useCache,
    interceptor = interceptor,
    paletteLoadedListener = paletteLoadedListener,
  )

The compose function triggers the palette generation:

  @Composable
  override fun compose(
    modifier: Modifier,
    imageModel: Any?,
    imageOptions: ImageOptions,
    imageBitmap: ImageBitmap?,
  ): ImagePlugin =
    apply {
      if (LocalInspectionMode.current) return@apply

      imageBitmap?.let {
        bitmapPalette.applyImageModel(this.imageModel ?: imageModel)
        bitmapPalette.generate(it)
      }
    }
}

This demonstrates that SuccessStatePlugin is not limited to visual rendering. The compose function performs a side effect: generating a color palette from the loaded bitmap and notifying through the paletteLoadedListener. The LocalInspectionMode check skips palette generation during IDE preview, since there is no real bitmap available in preview mode. The useCache flag avoids redundant palette generation when the same image is recomposed without changing.

Because LandscapistImage provides the imageBitmap directly from its internal decoding pipeline, PalettePlugin does not need to re decode the image or request it from a platform specific API. This is one of the benefits of the standalone engine: the bitmap is already available as a Compose ImageBitmap, ready for analysis.

ZoomablePlugin: Wrapping the entire content

ZoomablePlugin implements ComposablePlugin, which gives it a different scope than the other four plugin types. Instead of operating on a specific loading state or transforming the painter, it wraps the entire rendered image content:

@Immutable
public data class ZoomablePlugin(
  public val state: ZoomableState? = null,
  public val enabled: Boolean = true,
  public val onTransformChanged: ((ContentTransformation) -> Unit)? = null,
) : ImagePlugin.ComposablePlugin {

  @Composable
  override fun compose(content: @Composable () -> Unit) {
    val zoomableState = state ?: rememberZoomableState()
    val transformation = zoomableState.transformation
    onTransformChanged?.invoke(transformation)

    ZoomableContent(
      zoomableState = zoomableState,
      config = zoomableState.config,
      enabled = enabled,
      content = content,
    )
  }
}

The content lambda contains whatever LandscapistImage has rendered for the current state, including any modifications from painter plugins. ZoomableContent wraps this content with a gesture detector that handles pinch to zoom, pan, and double tap. The user sees the image and can interact with it without any additional code at the call site.

The optional state parameter is what makes this plugin particularly useful. If you pass in a ZoomableState from outside, you can read the current zoom level, pan offset, and rotation from other parts of your composable tree. You could display a zoom percentage indicator, add a "reset zoom" button, or synchronize zoom across multiple images. If you do not pass a state, the plugin creates one internally, keeping the simple case simple.

This is the most flexible plugin type because it has full control over how the image content is presented within the composable hierarchy. You could use ComposablePlugin to add overlays, gesture detectors, context menus, or any other composable wrapper around the image.

Putting it all together

With all five plugin types available, you can compose a complete image loading experience with LandscapistImage in a few lines:

LandscapistImage(
  imageModel = { imageUrl },
  component = rememberImageComponent {
    +ShimmerPlugin()
    +CircularRevealPlugin(duration = 350)
    +PalettePlugin(
      paletteLoadedListener = { palette ->
        dominantColor = palette.dominantSwatch?.rgb
      },
    )
    +PlaceholderPlugin.Failure(source = errorImage)
    +ZoomablePlugin()
  },
)

Each plugin handles one concern. The ShimmerPlugin shows an animated placeholder during loading. The CircularRevealPlugin animates the painter when the image arrives. The PalettePlugin extracts colors after loading. The PlaceholderPlugin.Failure displays an error image on failure. The ZoomablePlugin wraps the entire content with zoom gestures. Five plugins, five different lifecycle hooks, zero manual state management.

Because the plugin system operates at the Landscapist layer above any specific image loader, the same component configuration also works with GlideImage, CoilImage, and FrescoImage. But LandscapistImage gives you the added benefit of a standalone, multiplatform engine with no third party loader dependency. Your plugins, your loader, and your UI all run from a single Kotlin Multiplatform codebase.

In this article, you've explored the ImagePlugin sealed interface and its five subtypes, how ImagePluginComponent collects plugins through a DSL, and how built in plugins like PlaceholderPlugin, ShimmerPlugin, CircularRevealPlugin, PalettePlugin, and ZoomablePlugin implement each plugin type in practice.

Understanding this plugin architecture opens up a wide range of practical use cases. Because each plugin is self contained and attaches with a single + line, you can freely mix and match behaviors for increasingly complex scenarios, a shimmer placeholder combined with a circular reveal and palette extraction, for example, without the complexity scaling with the number of features. When requirements change, you detach a plugin by removing one line and attach a new one in its place.

As always, happy coding!

— Jaewoong (skydoves)