About ink-app-builder
A skill for building Android drawing apps with the `androidx.ink` API. It guides coding agents through setup, low-latency Compose drawing surfaces, stroke rendering, and related implementation workflows.
Key features
- 01Android Ink dependency and SDK setup
- 02Wet and dry ink Compose rendering
- 03Stock brush selection and switching
- 04Eraser, undo, and redo guidance
- 05Stroke persistence and bitmap export
Use cases
- 01Adding `androidx.ink` to an Android app
- 02Building a Compose screen for stylus drawing
- 03Implementing stroke editing and history
- 04Saving or exporting drawing data
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ android/ skills --skill ink-app-builderAfter installing, type/ink-app-builderin Claude Code to confirm it loaded.
Bundled files
References and scripts installed along with SKILL.md.
references/drawing-and-brushes.mdediting-and-architecture.mdpersistence-and-advanced.mdreferences/android/develop/ui/compose/touch-input/stylus-input/ink-api-brush-apis.mdink-api-draw-stroke.mdink-api-geometry-apis.mdink-api-setup.mdink-api-state-preservation.mdDetails
From the project READMEThe 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.