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):
kotlin/** * 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:
kotlin@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.


