ink-app-builder について
`androidx.ink` を使った Android 描画アプリ開発を支援するスキルです。依存関係の設定、低遅延な Compose 描画画面、ストローク処理の実装手順を案内します。
主な機能
- 01Android Ink の依存関係と SDK 設定
- 02Compose によるウェット・ドライインク描画
- 03標準ブラシの選択と切り替え
- 04消しゴム、元に戻す、やり直しの実装案内
- 05ストロークの保存とビットマップ出力
活用シーン
- 01Android アプリへの `androidx.ink` の追加
- 02スタイラス描画用 Compose 画面の構築
- 03ストローク編集と履歴機能の実装
- 04描画データの保存や出力
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ android/ skills --skill ink-app-builderインストール後、Claude Code で/ink-app-builderを入力して読み込まれたか確認します。
同梱ファイル
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.md詳細
Android Ink API(androidx.ink)は、Android で高性能かつ低遅延のフリーハンド描画エクスペリエンスを構築するための、モジュール式 Jetpack ライブラリです。ウェットインクとドライインクの 2 段階モデルを使用します。
- ウェットインク:ユーザーが描画している間、
InProgressStrokesコンポーザブルがリアルタイムで超低遅延のレンダリングを処理します。 - ドライインク:確定済みの不変な
Strokeオブジェクトを、アプリがCanvasStrokeRendererを使用してレンダリングします。
セットアップと依存関係
Ink API には minSdk = 26 が必要です。これは Android 8.0(API レベル 26)以降に相当します。すべての androidx.ink モジュールで、gradle/libs.versions.toml 内のバージョンを同一にする必要があります。プロジェクトのビルドファイルを変更する前に、ユーザーの許可を得てください。
バージョンの選択(1.0.0 安定版と 1.1.0-alpha03 以降)
StockBrushesまたはBrushFamily.decodeでデコードする、事前にシリアル化された.brushアセットに依存する本番アプリでは、**1.0.0(安定版)**を使用します。1.0.0安定版では、BrushFamily.encode、BrushFamily.decode、AndroidBrushFamilySerialization、BrushFamilyDecodeCallbackに@OptIn(ExperimentalInkCustomBrushApi::class)が必要です。- Kotlin コードでプログラムによるカスタムブラシ(
BrushFamily、BrushCoat、BrushTip、BrushPaint、BrushBehavior)を構築するアプリでは、1.1.0-alpha03以降を使用します。1.1.0-alpha02以降では、BrushFamilyのシリアル化に@OptInは不要です(また、1.1.0-alpha05以降ではExperimentalInkCustomBrushApiは@RestrictTo(LIBRARY_GROUP)となっているため、1.1.0-alpha03以降のコードにこれを付けてはいけません)。
バージョンカタログ(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/build.gradle.kts)
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp) // Kotlin で Room を使用する場合は必須です
}
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) // Kotlin では annotationProcessor は絶対に使用しないでください
}
描画の基本キャンバスとデフォルトのブラシ
ink-brush-compose の Brush.createWithComposeColor を使って、標準的な筆圧対応のペン Brush を作成します。epsilon パラメーターは、2 つの入力ポイントを視覚的に異なるものとして扱うための最小距離を、ストローク空間の単位で定義します(一般的な描画では 0.1f、高精度ツールでは 0.01f を使用します。Brush では 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,
)
}
描画キャンバスでは、Box の中に 2 つのレイヤーを重ねます。Compose Canvas は nativeCanvas.withSave 内で CanvasStrokeRenderer を使って確定済みのドライ Stroke オブジェクトを描画し、その上に InProgressStrokes コンポーザブルを配置して、低レイテンシーのウェットインクをキャプチャします。DrawingSurface 内に同期的な pendingStrokes Compose Snapshot State バッファーをカプセル化することで、onStrokesFinished が発火した際に、同じ UI スレッドの実行ループ内でドライ Canvas を無効化し、ウェットからドライへの切り替え時に 1 フレームのちらつきが発生するのを防ぎます。
@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,
)
}
}
}
}
## ワークフローとタスクの振り分け
依頼されたタスクに応じて、以下の条件付き指示に従ってください。
- ステップ 1(依存関係と SDK のセットアップ):Ink API の依存関係を追加する場合、または
minSdkを設定する場合は、このファイルのセットアップブロックと Ink API セットアップガイドに従ってください。ink-nativeloader、ink-authoring-android、androidx.input:input-motionpredictionが含まれていることを確認してください。 - ステップ 2(描画サーフェスとストロークのレンダリング):ウェットインク(
InProgressStrokes)とドライインク(CanvasStrokeRenderer)を使った Compose の描画画面を作成する場合は、このファイルのDrawingSurfaceを実装し、ストロークを描画するを確認してください。InProgressStrokesのパラメーターの詳細、BlendMode.Multiplyによる蛍光ペンレイヤーの合成、TextureBitmapStoreの生成無効化については、必ず drawing-and-brushes.md に従ってください。 - ステップ 3(標準ブラシとブラシの切り替え):標準ブラシ(
StockBrushes.pressurePen、marker、highlighter、dashedLine、またはemojiHighlighter)を作成、変更、または切り替える場合は、ブラシ APIを確認し、epsilonのジオメトリ制約、蛍光ペンのアルファ値の保持、Android 14(API レベル 34)のemojiHighlighterのルールについて、必ず drawing-and-brushes.md に従ってください。 - ステップ 4(消しゴム、元に戻す・やり直し、MVVM アーキテクチャ):ジオメトリベースのストローク消しゴム、線形の元に戻す・やり直し履歴スタック、または MVVM の
ViewModelと Hilt の接続を実装する場合は、ジオメトリ APIを確認し、必ず editing-and-architecture.md に従ってください。 - ステップ 5(永続化、ビットマップへのエクスポート、ジェスチャーの共存):Room を使ってストロークを保存・読み込む(
StrokeInputBatch.encodeとdecode)場合、構成変更をまたいで UI の状態を保持する場合、描画をBitmapまたは PNG ファイルにエクスポートする場合、または長押しドラッグジェスチャーと描画(pointerInputWithSiblingFallthrough)を組み合わせる場合は、状態の保持と永続ストレージを確認し、必ず persistence-and-advanced.md に従ってください。 - プログラムでのカスタムブラシ作成:カスタム
BrushFamilyの作成、カスタムの先端形状、または動作ノードグラフについては、ink-custom-brush-builderスキルを提案してください。
アンチパターン
- Compose の画面では
InProgressStrokesViewを絶対に使用しないでください ---これは View ベースの API です。代わりにInProgressStrokesコンポーザブルを使用してください。 ink-nativeloaderまたはink-authoring-androidを絶対に省略しないでください ---ink-nativeloaderがないとUnsatisfiedLinkErrorが発生し、ink-authoring-androidがないと実行時にClassNotFoundExceptionが発生します。View.OnTouchListenerやMotionEventを直接絶対に使用しないでください ---InProgressStrokesコンポーザブルが、タッチ入力とスタイラス入力を内部でキャプチャして処理します。Canvas.drawPathを使ったり、ストロークの各点を反復処理したりして、ストロークを手動で絶対に描画しないでください---必ずCanvasStrokeRenderer.draw(canvas, stroke, transform)を呼び出してください。CanvasStrokeRenderer.create(forcePathRendering = true)を絶対に呼び出さないでください ---2 引数のforcePathRenderingオーバーロードは@RestrictTo(LIBRARY_GROUP)です。ソフトウェアBitmapキャンバスを自動検出し、パス描画にフォールバックする公開ファクトリーCanvasStrokeRenderer.create(textureStore)を使用してください。onStrokesFinishedで非同期のStateFlowの収集だけに絶対に頼らないでください ---InProgressStrokesは、UI スレッドでonStrokesFinishedが戻った直後に完了したウェットストロークを削除しますが、collectAsStateWithLifecycleは後続のイベントループで更新されるため、ウェットからドライへの切り替わりで 1 フレームのちらつきが発生します。DrawingSurfaceの例のように、pendingStrokesバッファーを使用して、同じ UI スレッドのイベントループ内で Compose の Snapshot State を同期的に必ず更新してください。- 取り消し、やり直し、またはデータベースへの永続化のために、ストロークをビットマップとして絶対に保存しないでください---メモリー上には
Strokeオブジェクトを保存し、ディスクへの保存にはブラシのメタデータとともにStrokeInputBatchのバイト列をシリアル化してください。 - Kotlin で Room に
annotationProcessorを絶対に使用しないでください ---起動時にAppDatabase_Impl does not existのクラッシュが発生するのを防ぐため、必ずcom.google.devtools.kspプラグインを適用し、ksp(libs.androidx.room.compiler)を使用してください。