How Compose's Drawing System Works Under the Hood
How Compose's Drawing System Works Under the Hood
Every Compose app draws images. Whether you call Image(painterResource(R.drawable.photo)) to display a bitmap, render a Material icon with Icon(Icons.Default.Search), or load a vector drawable, the same underlying abstraction handles the actual drawing: the Painter class. Painter is to Compose what Drawable is to the View system, a layer that knows how to draw content into a bounded area while handling alpha, color filters, and layout direction.
In this article, you'll explore the full drawing pipeline from the abstract Painter class through its concrete implementations (BitmapPainter for raster images and VectorPainter for vector graphics), the immutable ImageVector data structure and the mutable VectorComponent render tree that draws it, the DrawCache that caches rendered vectors as bitmaps for performance, painterResource() which dispatches between bitmap and vector formats, and the Image and Icon composables that connect painters to the layout system.
The fundamental problem: One API for many image formats
Android has two fundamentally different kinds of image assets. Bitmaps (PNG, JPG, WEBP) are grids of pixels. Vector drawables are XML files containing mathematical path descriptions, lines, curves, and fills expressed as coordinate instructions. A bitmap stores the exact color value of every pixel, while a vector stores instructions for how to draw the image at any size.
Compose needs a single abstraction that layout composables like Image and Icon can use without knowing the underlying image format. A composable that displays a photo and one that displays a search icon should both work through the same interface. The Painter abstraction solves this problem. It defines two things every image format must provide: an intrinsicSize reporting the image's natural dimensions and an onDraw() method that knows how to render it. Each format provides its own implementation.
Painter: The drawing abstraction
Think of Painter like a print shop that accepts any original, whether it is a photograph, an illustration, or a piece of vector art, and produces output at the requested size. The consumer hands over the original and specifies dimensions, and the shop handles the rest. The consumer does not need to know whether the source was a JPEG or an SVG file.
The Painter abstract class defines the contract that all image sources must implement. If you look at its core structure (simplified):
abstract class Painter {
private var layerPaint: Paint? = null
private var useLayer = false
private var alpha: Float = DefaultAlpha
private var colorFilter: ColorFilter? = null
abstract val intrinsicSize: Size
protected abstract fun DrawScope.onDraw()
protected open fun applyAlpha(alpha: Float): Boolean = false
protected open fun applyColorFilter(colorFilter: ColorFilter?): Boolean = false
protected open fun applyLayoutDirection(layoutDirection: LayoutDirection): Boolean = false
}
Each Painter subclass reports its natural dimensions through intrinsicSize. A BitmapPainter returns the pixel dimensions of its image. A VectorPainter returns the dp based default size. If a painter has no intrinsic size, like ColorPainter which fills any area with a solid color, it returns Size.Unspecified.
The optimization hooks applyAlpha() and applyColorFilter() are where the design gets interesting. These methods return a Boolean. If the subclass returns true, it means "I'll handle this effect directly." If it returns false, the base class falls back to rendering into an offscreen layer using withSaveLayer, which works universally but costs an extra buffer allocation. This opt in pattern lets simple painters like BitmapPainter avoid the offscreen layer entirely.
The draw() method ties everything together. It configures alpha and color filter, insets the drawing area to the requested size, then decides whether to use a layer or call onDraw() directly:
fun DrawScope.draw(size: Size, alpha: Float = DefaultAlpha, colorFilter: ColorFilter? = null) {
configureAlpha(alpha)
configureColorFilter(colorFilter)
configureLayoutDirection(layoutDirection)
inset(
left = 0.0f, top = 0.0f,
right = this.size.width - size.width,
bottom = this.size.height - size.height,
) {
if (alpha > 0.0f && size.width > 0 && size.height > 0) {
if (useLayer) {
val layerRect = Rect(Offset.Zero, Size(size.width, size.height))
drawIntoCanvas { canvas ->
canvas.withSaveLayer(layerRect, obtainPaint()) { onDraw() }
}
} else {
onDraw()
}
}
}
}
When useLayer is true, the base class renders content into a saveLayer with a Paint object that carries the alpha and color filter. When useLayer is false, it calls onDraw() directly, skipping the intermediate buffer entirely.
BitmapPainter: Drawing raster images
BitmapPainter is the simplest Painter implementation. It wraps an ImageBitmap and draws it using drawImage():
class BitmapPainter(
private val image: ImageBitmap,
private val srcOffset: IntOffset = IntOffset.Zero,
private val srcSize: IntSize = IntSize(image.width, image.height),
) : Painter() {
internal var filterQuality: FilterQuality = FilterQuality.Low
private var alpha: Float = 1.0f
private var colorFilter: ColorFilter? = null
override val intrinsicSize: Size
get() = srcSize.toSize()
The srcOffset and srcSize parameters support drawing a subsection of the bitmap. This is useful for sprite sheets or cropped regions where you want to display only a portion of a larger image.
The onDraw() implementation passes all parameters directly to drawImage():
override fun DrawScope.onDraw() {
drawImage(
image, srcOffset, srcSize,
dstSize = IntSize(
this@onDraw.size.width.fastRoundToInt(),
this@onDraw.size.height.fastRoundToInt(),
),
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality,
)
}
override fun applyAlpha(alpha: Float): Boolean {
this.alpha = alpha
return true
}
override fun applyColorFilter(colorFilter: ColorFilter?): Boolean {
this.colorFilter = colorFilter
return true
}
}
Both applyAlpha() and applyColorFilter() return true and store the values internally. This tells the base Painter class that BitmapPainter handles these effects itself by passing them directly to drawImage(), avoiding the offscreen layer overhead entirely.
This article continues for subscribers
Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.
Become a Sponsor