mcpField
I
ink-app-builder

ink-app-builder

github.com/android開発10.0k47 回インストール2 日前に更新
リポジトリ

ink-app-builder について

`androidx.ink` を使った Android 描画アプリ開発を支援するスキルです。依存関係の設定、低遅延な Compose 描画画面、ストローク処理の実装手順を案内します。

主な機能

  1. 01Android Ink の依存関係と SDK 設定
  2. 02Compose によるウェット・ドライインク描画
  3. 03標準ブラシの選択と切り替え
  4. 04消しゴム、元に戻す、やり直しの実装案内
  5. 05ストロークの保存とビットマップ出力

活用シーン

  1. 01Android アプリへの `androidx.ink` の追加
  2. 02スタイラス描画用 Compose 画面の構築
  3. 03ストローク編集と履歴機能の実装
  4. 04描画データの保存や出力

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/android/skills --skill ink-app-builder

インストール後、Claude Code で/ink-app-builderを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

references/
drawing-and-brushes.md
editing-and-architecture.md
persistence-and-advanced.md
references/android/develop/ui/compose/touch-input/stylus-input/
ink-api-brush-apis.md
ink-api-draw-stroke.md
ink-api-geometry-apis.md
ink-api-setup.md
ink-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) を使用してください。

コメント