The Android Ink API (androidx.ink) is a modular Jetpack library for building
high-performance, low-latency freehand drawing experiences on Android. It uses a
wet ink and dry ink two-phase model:
- Wet ink: Real-time, ultra-low-latency rendering while the user is drawing,
handled by the
InProgressStrokescomposable. - Dry ink: Finalized, immutable
Strokeobjects rendered by the app usingCanvasStrokeRenderer.
Setup and dependencies
The Ink API requires minSdk = 26, which corresponds to Android 8.0 (API
level 26) and higher. All androidx.ink modules MUST share an identical version
in gradle/libs.versions.toml. Ask user permission before modifying project
build files.
Version selection (1.0.0 stable versus 1.1.0-alpha03 and higher)
- Use
1.0.0(Stable) for production apps that rely onStockBrushesor pre-serialized.brushassets decoded withBrushFamily.decode. In1.0.0stable,BrushFamily.encode,BrushFamily.decode,AndroidBrushFamilySerialization, andBrushFamilyDecodeCallbackrequire@OptIn(ExperimentalInkCustomBrushApi::class). - Use
1.1.0-alpha03and higher when the app constructs programmatic custom brushes (BrushFamily,BrushCoat,BrushTip,BrushPaint,BrushBehavior) in Kotlin code. Starting in1.1.0-alpha02and higher,BrushFamilyserialization requires no@OptIn(and in1.1.0-alpha05and higher,ExperimentalInkCustomBrushApiis@RestrictTo(LIBRARY_GROUP), so NEVER annotate1.1.0-alpha03and higher code with it).
Version catalog (gradle/libs.versions.toml)
[versions]
ink = "<latest_version>"
inputMotionPrediction = "<latest_version>"
[libraries]
# Core Ink modules
androidx-ink-authoring = { module = "androidx.ink:ink-authoring", version.ref = "ink" }
androidx-ink-brush = { module = "androidx.ink:ink-brush", version.ref = "ink" }
androidx-ink-geometry = { module = "androidx.ink:ink-geometry", version.ref = "ink" }
androidx-ink-nativeloader = { module = "androidx.ink:ink-nativeloader", version.ref = "ink" }
androidx-ink-rendering = { module = "androidx.ink:ink-rendering", version.ref = "ink" }
androidx-ink-strokes = { module = "androidx.ink:ink-strokes", version.ref = "ink" }
androidx-ink-storage = { module = "androidx.ink:ink-storage", version.ref = "ink" }
# Compose integration modules
androidx-ink-authoring-android = { module = "androidx.ink:ink-authoring-android", version.ref = "ink" }
androidx-ink-authoring-compose = { module = "androidx.ink:ink-authoring-compose", version.ref = "ink" }
androidx-ink-brush-compose = { module = "androidx.ink:ink-brush-compose", version.ref = "ink" }
androidx-ink-geometry-compose = { module = "androidx.ink:ink-geometry-compose", version.ref = "ink" }
# Motion prediction (required for low-latency stylus tracking)
androidx-input-motionprediction = { module = "androidx.input:input-motionprediction", version.ref = "inputMotionPrediction" }
# Persistence (kotlinx.serialization + Room with KSP)
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
App module configuration (app/build.gradle.kts)
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp) // Mandatory when using Room in Kotlin
}
android {
defaultConfig {
minSdk = 26
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.androidx.ink.authoring)
implementation(libs.androidx.ink.brush)
implementation(libs.androidx.ink.geometry)
implementation(libs.androidx.ink.nativeloader)
implementation(libs.androidx.ink.rendering)
implementation(libs.androidx.ink.strokes)
implementation(libs.androidx.ink.storage)
implementation(libs.androidx.ink.authoring.android)
implementation(libs.androidx.ink.authoring.compose)
implementation(libs.androidx.ink.brush.compose)
implementation(libs.androidx.ink.geometry.compose)
implementation(libs.androidx.input.motionprediction)
implementation(libs.kotlinx.serialization.json)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler) // NEVER use annotationProcessor in Kotlin
}
Core drawing surface and default brush
Create a standard pressure-sensitive pen Brush with
Brush.createWithComposeColor (from ink-brush-compose). The epsilon
parameter defines the minimum distance between two input points, in stroke-space
units, to be considered visually distinct (0.1f for general drawing, 0.01f
for fine precision tools; Brush enforces 0f < epsilon <= size):
/** * Creates a default pressure-sensitive pen [Brush]. * * The `epsilon` parameter sets the geometric tolerance (minimum point distance * in stroke units) controlling how far a stroke can be zoomed before triangle * artifacts appear (`0f < epsilon <= size`). */ fun createDefaultPenBrush(): Brush { return Brush.createWithComposeColor( family = StockBrushes.pressurePen(), color = Color.Black, size = 5f, epsilon = 0.1f, ) }
Stack two layers inside a Box for the drawing surface: a Compose Canvas that
renders finalized dry Stroke objects inside nativeCanvas.withSave using
CanvasStrokeRenderer, and an InProgressStrokes composable on top for
low-latency wet ink capture. Encapsulate a synchronous pendingStrokes Compose
Snapshot State buffer inside DrawingSurface so the dry Canvas invalidates in
the exact same UI thread run loop when onStrokesFinished fires, preventing a
1-frame wet-to-dry flicker:
@Composable fun DrawingSurface( strokes: List<Stroke>, canvasStrokeRenderer: CanvasStrokeRenderer, currentBrush: Brush, onGetNextBrush: () -> Brush, onStrokesFinished: (List<Stroke>) -> Unit, isEraserMode: Boolean, modifier: Modifier = Modifier, textureStore: AppTextureBitmapStore = LocalTextureStore.current, onErase: (Float, Float) -> Unit = { _, _ -> }, onEraseStart: () -> Unit = {}, onEraseEnd: () -> Unit = {}, ) { val cacheGen by textureStore.generation.collectAsStateWithLifecycle() // Holds newly finished strokes synchronously for the single frame before // asynchronous StateFlow collection delivers the updated strokes list. var pendingStrokes by remember { mutableStateOf<List<Stroke>>(emptyList()) } SideEffect { if (pendingStrokes.isNotEmpty()) { pendingStrokes = if (strokes.isEmpty()) { emptyList() } else { pendingStrokes.filter { pending -> strokes.none { it === pending } } } } } val strokesToDraw = remember(strokes, pendingStrokes) { if (pendingStrokes.isEmpty()) { strokes } else { strokes + pendingStrokes.filter { pending -> strokes.none { it === pending } } } } val identityMatrix = remember { Matrix() } val currentOnErase by rememberUpdatedState(onErase) val currentOnEraseStart by rememberUpdatedState(onEraseStart) val currentOnEraseEnd by rememberUpdatedState(onEraseEnd) Box(modifier = modifier) { // Layer 1: Dry ink - completed strokes + same-frame pending handoff strokes Canvas(modifier = Modifier.fillMaxSize()) { val nativeCanvas = drawContext.canvas.nativeCanvas strokesToDraw.fastForEach { stroke -> nativeCanvas.withSave { canvasStrokeRenderer.draw( canvas = this, stroke = stroke, strokeToScreenTransform = identityMatrix, ) } } } // Layer 2: Eraser touch handler OR Wet ink (InProgressStrokes) if (isEraserMode) { Box( modifier = Modifier .fillMaxSize() .pointerInput(Unit) { detectDragGestures( onDragStart = { currentOnEraseStart() }, onDragEnd = { currentOnEraseEnd() }, onDragCancel = { currentOnEraseEnd() }, onDrag = { change, _ -> currentOnErase(change.position.x, change.position.y) change.consume() }, ) } ) } else { key(cacheGen) { InProgressStrokes( defaultBrush = currentBrush, nextBrush = onGetNextBrush, onStrokesFinished = { newStrokes -> pendingStrokes = pendingStrokes + newStrokes onStrokesFinished(newStrokes) }, textureBitmapStore = textureStore, ) } } } }
Workflow and task routing
Follow these conditional instructions based on the requested task:
- Step 1 (Dependencies and SDK setup): When adding Ink API dependencies or
configuring
minSdk, follow the setup blocks in this file and the Ink API setup guide. Verify thatink-nativeloader,ink-authoring-android, andandroidx.input:input-motionpredictionare present. - Step 2 (Drawing surface and stroke rendering): When creating a Compose
drawing screen with wet ink (
InProgressStrokes) and dry ink (CanvasStrokeRenderer), implementDrawingSurfacefrom this file and review Draw a stroke. ForInProgressStrokesparameter details,BlendMode.Multiplyhighlighter layer compositing, andTextureBitmapStoregeneration invalidation, you MUST follow drawing-and-brushes.md. - Step 3 (Stock brushes and brush switching): When creating, modifying, or
switching stock brushes (
StockBrushes.pressurePen,marker,highlighter,dashedLine, oremojiHighlighter), review Brush APIs and you MUST follow drawing-and-brushes.md forepsilongeometry constraints, highlighter alpha preservation, and Android 14 (API level 34)emojiHighlighterrules. - Step 4 (Eraser, undo and redo, and MVVM architecture): When implementing a
geometry-based stroke eraser, linear undo and redo history stacks, or MVVM
ViewModeland Hilt wiring, review Geometry APIs and you MUST follow editing-and-architecture.md. - Step 5 (Persistence, bitmap export, and gesture coexistence): When saving
and loading strokes with Room (
StrokeInputBatch.encodeanddecode), preserving UI state across configuration changes, exporting drawings to aBitmapor PNG file, or combining long-press drag gestures with drawing (pointerInputWithSiblingFallthrough), review State preservation and persistent storage and you MUST follow persistence-and-advanced.md. - Programmatic custom brushes: For custom
BrushFamilycreation, custom tip shapes, or behavior node graphs, suggest theink-custom-brush-builderskill.
Antipatterns
- NEVER use
InProgressStrokesViewin Compose screens—that's the View-based API. Use theInProgressStrokescomposable instead. - NEVER omit
ink-nativeloaderorink-authoring-android—missingink-nativeloaderthrowsUnsatisfiedLinkError, and missingink-authoring-androidthrowsClassNotFoundExceptionat runtime. - NEVER use
View.OnTouchListenerorMotionEventdirectly—theInProgressStrokescomposable captures and processes touch and stylus input internally. - NEVER draw strokes manually with
Canvas.drawPathor by iterating stroke points—always callCanvasStrokeRenderer.draw(canvas, stroke, transform). - NEVER call
CanvasStrokeRenderer.create(forcePathRendering = true)—the 2-argumentforcePathRenderingoverload is@RestrictTo(LIBRARY_GROUP). Use the publicCanvasStrokeRenderer.create(textureStore)factory, which automatically detects softwareBitmapcanvases and falls back to path rendering. - NEVER rely solely on asynchronous
StateFlowcollection inonStrokesFinished—InProgressStrokesremoves completed wet strokes immediately afteronStrokesFinishedreturns on the UI thread, whilecollectAsStateWithLifecycleupdates on a later run loop, causing a 1-frame wet-to-dry flicker. Always mutate Compose Snapshot State synchronously in the same UI thread run loop using apendingStrokesbuffer as shown inDrawingSurface. - NEVER store strokes as bitmaps for undo, redo, or database
persistence—store
Strokeobjects in memory and serializeStrokeInputBatchbytes with brush metadata for disk storage. - NEVER use
annotationProcessorfor Room in Kotlin—always apply thecom.google.devtools.kspplugin and useksp(libs.androidx.room.compiler)to avoidAppDatabase_Impl does not existcrashes at launch.