Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

Ink APIさわってみた

こんにちは、Androidグループに所属しているにゃふんた(nyafunta9858)です。 今回は、なかなか試せていなかったAndroidXの新しいライブラリを弊社のYappdateDayを使って触ってみましたので、そちらについてお話してみたいと思います。

YappdateDayについて

YappdateDayでは普段どういったことに取り組んでいるのか?といったところは過去の記事にまとまっておりますので、よろしければ合わせてお読みください。

times.yappli.co.jp tech.yappli.io

YappdateDayは普段ではなかなか着手できていない軽微な不具合や改善、技術的負債の返済活動など、ヤプリの掲げているValueのひとつ「再構築」にフォーカスする日となっています。 本来であれば再構築へフォーカスしたり新機能開発へのファーストステップとできるとベストなのですが、新機能を提案するにもアイディアの種を仕込んでいくのも大事かと思いますので、今回はファーストステップを踏むための数手前の段階として新しいものにサクッと触れてみようという魂胆のもと触れてみました。

今回試したライブラリ

さて、今回試してみましたのは、今秋にアルファ版がリリースされたAndroidXのInk APIです。

developer.android.com

Ink APIはInking(手書き入力)機能を提供するAndroidXのライブラリで、Android アプリケーションへの手書き入力機能の導入を簡単にしてくれます。

また、Ink APIは機能ごとにモジュールで提供されているため、必要なものを選んで利用することが出来ます。 今回は基本的な手書き入力部分をひとまず触って感触を掴みたいと思っていたので、Geometryモジュールを使った消しゴム機能を作ってみるといったことはスコープから外しています。

developer.android.com

手書き入力を試す、その前に

今回は次の2つの機能を組み込んでみました。

  • 手書き入力中の軌跡を線で描画
  • 手書き入力された線を画面に描画・反映

公式ページを参考に進めれば、上記を実現する処理の大半は問題なく実現、動作するかと思いますが、API Level 35以上の開発環境が必要になります。API Levelが34以下の場合は以下のようなエラーが出力されてコンパイルエラーとなりましたので、その点ご注意下さい。

環境構築

さて実際にコードを動かすために、まずは環境構築としてInk APIを使うのに必要なモジュールを取り込みます。公式ページのサンプルコードにあるとおりの定義を記載していますが、先述のとおりink-geometryの機能は今回は使わないため、本稿にあるサンプルコードを実行する際には取り込まなくても問題ありません。

またinput-motionpredictionというライブラリがしれっと入っていますが、こちらは過去に受信したタッチイベントを基に予測されたMotionEventを取得できるMotionEventPredictorというクラスを提供してくれています。将来のタッチイベントを予測することでパフォーマンスの改善が期待されるため利用が薦められています。

dependencies {
    // Ink APIs
    implementation("androidx.ink:ink-authoring:1.0.0-alpha02")
    implementation("androidx.ink:ink-brush:1.0.0-alpha02")
    implementation("androidx.ink:ink-geometry:1.0.0-alpha02")
    implementation("androidx.ink:ink-nativeloader:1.0.0-alpha02")
    implementation("androidx.ink:ink-rendering:1.0.0-alpha02")
    implementation("androidx.ink:ink-strokes:1.0.0-alpha02")
    // Motion Prediction
    implementation("androidx.input:input-motionprediction:1.0.0-beta05")
}

実際に手書き入力を試してみた

今回試した中では以下の流れで手書き入力を画面へ描画しています。

  1. 手書き入力を受け付けるViewのセットアップ
  2. MotionEvent.ACTION_DOWN受信後、手書き入力の開始をAPIへ通知
  3. MotionEvent.ACTION_MOVE受信後、手書き入力の更新をAPIへ通知
  4. MotionEvent.ACTION_UP受信後、手書き入力の終了をAPIへ通知
  5. Canvasへ描画する線をを保持するため、手書き入力の終了を検知するリスナーを登録
  6. Canvasへ 5.で保持した手書き入力の軌跡を描画

1. 手書き入力を受け付けるViewのセットアップ

まずは手書き入力した軌跡を表示するためのViewInProgressStrokesViewを用意して、Jetpack ComposeでUIを構築する準備をします。コンストラクタでContextとしてActivityを渡していますが、ここは実装する場所に合わせて適宜読み替えてください。

// 手書き入力の軌跡を表示するView
private lateinit var inProgressStrokesView: InProgressStrokesView

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    :
    inProgressStrokesView = InProgressStrokesView(this@SampleActivity)
    setContent {
        InkingView(inProgressStrokesView)
    }
}

2. MotionEvent.ACTION_DOWN受信後、手書き入力の開始をAPIへ通知

ここからはMotionEventを使って実際にInk APIを呼び出し、利用します。 最後にソースコードの全体像を載せますので、ポイントとなる処理をピックアップして触れていきたいと思います。

先ずはMotionEvent.ACTION_DOWN受信時に行っている以下の処理です。

// 手書き入力の開始
// inProgressStrokesViewに入力の開始を伝えています
MotionEvent.ACTION_DOWN -> {  
    view.requestUnbufferedDispatch(event)  
    val pointerId = event.getPointerId(event.actionIndex)  
    currentPointerId.value = pointerId  
    currentStrokeId.value = inProgressStrokesView.startStroke(  
        event = event,  
        pointerId = pointerId,  
        brush = inProgressStrokeBrush  
    )  
    true  
}  

ここでは手書き入力された軌跡、Strokeを描画するためにInProgressStrokesView.startStrokeを呼び出しています。currentPointerIdcurrentStrokeIdを保持していますが、これらはInProgressStrokesViewStrokeの状態更新を依頼する際に必要になるためです。この後にも度々出てきます。 また、View.requestUnbufferedDispatchを呼び出すことでInProgressStrokesViewMotionEvent.ACTION_UPを受け取るまでの間、入力システムがMotionEventをバッチ処理せずに利用可能になったらすぐに配信するように要求しています。

3. MotionEvent.ACTION_MOVE受信後、手書き入力の更新をAPIへ通知

MotionEvent.ACTION_MOVEを受信したらInProgressStrokesView.addToStrokeを使ってStrokeの更新を行います。 処理自体は非常にシンプルで、先述で保持しておいたPointerIDを使って期待するポインターを探し、StrokeIDおよびMotionEventと一緒にInProgressStrokesViewへ渡して更新を行っています。

// 手書き入力中
// inProgressStrokesViewに入力中の軌跡を伝えています
MotionEvent.ACTION_MOVE -> {  
    val pointerId = checkNotNull(currentPointerId.value)  
    val strokeId = checkNotNull(currentStrokeId.value)  

    for (pointerIndex in 0 until event.pointerCount) {  
        if (event.getPointerId(pointerIndex) != pointerId) {  
            continue  
        }  
        inProgressStrokesView.addToStroke(  
            event,  
            pointerId,  
            strokeId,  
            predictedEvent  
        )  
    }  
    true  
}  

4. MotionEvent.ACTION_UP受信後、手書き入力の終了をAPIへ通知

MotionEvent.ACTION_UP、すなわちタッチイベントの終わりに合わせて手書き入力も終了します。 手書き入力開始時にはInProgressStrokesView.startStrokeを呼び出していましたが、今度はInProgressStrokesView.finishStrokeを呼び出して手書き入力を終了します。startに始まってfinishで終了する、分かりやすいですね。ここでもPointerIDとStrokeIDを使ってAPIを呼び出しています。

// 手書き入力の終了
// inProgressStrokesViewに入力の終了を伝えています
MotionEvent.ACTION_UP -> {  
    val pointerId = event.getPointerId(event.actionIndex)  
    check(pointerId == currentPointerId.value)  
    val strokeId = checkNotNull(currentStrokeId.value)  
    inProgressStrokesView.finishStroke(  
        event,  
        pointerId,  
        strokeId  
    )  
    view.performClick()  
    true  
}  

キャンセルの場合はInProgressStrokesView.cancelStrokeを呼び出します。 期待したPointerIDとなっているかの確認とInProgressStrokesView.cancelStrokeの呼び出しのみとなっており、こちらもまたとてもシンプルな実装となっています。

// 手書き入力のキャンセル
// inProgressStrokesViewに入力がキャンセルされたことを伝えています
MotionEvent.ACTION_CANCEL -> {  
    val pointerId = event.getPointerId(event.actionIndex)  
    check(pointerId == currentPointerId.value)  
    val strokeId = checkNotNull(currentStrokeId.value)  
    inProgressStrokesView.cancelStroke(strokeId, event)  
    true  
}

5. Canvasへ描画する線をを保持するため、手書き入力の終了を検知するリスナーを登録

処理の流れとしてはここまでで手書き入力された軌跡が画面に表示されるようになっているかと思いますが、手書き入力を終えると入力していた軌跡が残らずに消えてしまいます。 あくまで手書き入力している間の軌跡を表示するだけですので、入力が完了したらそれまで入力していた情報を使って描画する必要があります。 そこで入力完了を検知するためのリスナー、InProgressStrokesFinishedListenerInProgressStrokesViewに追加して、手書き入力が完了したStrokeを受け取れるようにしておきます。

private val finishedStrokesState = mutableStateOf(emptySet<Stroke>())  

// inProgressStrokesViewへの手書き入力が終わったことを検知するためのリスナー
// 手書き入力された線をCanvasへ反映するためにStrokeの保持などを行っています
private val inProgressStrokesFinishListener = object : InProgressStrokesFinishedListener {  
    override fun onStrokesFinished(strokes: Map<InProgressStrokeId, Stroke>) {  
        super.onStrokesFinished(strokes)  
        finishedStrokesState.value += strokes.values  
        inProgressStrokesView.removeFinishedStrokes(strokes.keys)  
    }  
}

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    :
    inProgressStrokesView = InProgressStrokesView(this@SampleActivity).apply {
        addFinishedStrokesListener(inProgressStrokesFinishListener)
    }
    setContent {
        InkingView(inProgressStrokesView)
    }
}

6. Canvasへ 5.で保持した手書き入力の軌跡を描画

最後に、Canvasに対してStrokeを描画します。 StrokeCanvasへの描画にはCanvasStrokeRendererを使います。 ここでは手書き入力していた軌跡の色を手書き入力時とは違う色、Color.Magentaに変更した上で描画を試みています。

private lateinit var strokeRenderer: CanvasStrokeRenderer  

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    :
    inProgressStrokesView = InProgressStrokesView(this@SampleActivity).apply {
        addFinishedStrokesListener(inProgressStrokesFinishListener)
    }
    strokeRenderer = CanvasStrokeRenderer.create()

    setContent {
        InkingView(inProgressStrokesView)
    }
}

:

        // onStrokesFinishedで受け取った手書き入力された線(Stroke)をCanvasへ描画・反映しています
        Canvas(modifier = Modifier) {  
            val canvasTransform = Matrix()  
            val canvas = drawContext.canvas.nativeCanvas.apply {  
                concat(canvasTransform)  
            }  
            finishedStrokesState.value.forEach { stroke ->  
                strokeRenderer.draw(  
                    stroke = stroke.copy(  
                        stroke.brush.copyWithColorIntArgb(Color.Magenta.toArgb())  
                    ),  
                    canvas = canvas,  
                    strokeToScreenTransform = canvasTransform  
                )  
            }  
        }
    }
}

これまでの処理を実際に動かしてみる

これまでの処理も含め実装されたソースコードがこちらになります。Composable関数InkingViewの引数は詳しくは触れませんが、コメントで少し説明を記載しておりますのでご参考にしていただければと思います。

private lateinit var inProgressStrokesView: InProgressStrokesView  
private lateinit var strokeRenderer: CanvasStrokeRenderer  
private val finishedStrokesState = mutableStateOf(emptySet<Stroke>())  

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    :
    inProgressStrokesView = InProgressStrokesView(this@SampleActivity).apply {
        addFinishedStrokesListener(inProgressStrokesFinishListener)
    }
    strokeRenderer = CanvasStrokeRenderer.create()

    setContent {
        InkingView(inProgressStrokesView)
    }
} 
 
/**  
 * @param inProgressStrokesView スタイラスの軌跡を描画するためのView  
 * @param colorIntArgb 描画する線の色  
 * @param lineWidth 描画する線の太さ  
 * @param epsilon 描画する線の感覚, 小さい値であるほど正確な線になる  
 */
@Composable  
fun InkingView(  
    inProgressStrokesView: InProgressStrokesView,  
    @ColorInt  
    colorIntArgb: Int = Color.Black.toArgb(),  
    lineWidth: Float = 10F,  
    epsilon: Float = 0.1F,  
) {  
    val currentPointerId = remember { mutableStateOf<Int?>(null) }  
    val currentStrokeId = remember { mutableStateOf<InProgressStrokeId?>(null) }  
    val inProgressStrokeBrush = Brush.createWithColorIntArgb(  
        family = StockBrushes.pressurePenLatest,  
        colorIntArgb = colorIntArgb,  
        size = lineWidth,  
        epsilon = epsilon  
    )  
  
    Box(modifier = Modifier.fillMaxSize()) {  
        AndroidView(  
            modifier = Modifier.fillMaxSize(),  
            factory = { context ->  
                val rootView = FrameLayout(context)  
                inProgressStrokesView.apply {  
                    layoutParams = FrameLayout.LayoutParams(  
                        FrameLayout.LayoutParams.MATCH_PARENT,  
                        FrameLayout.LayoutParams.MATCH_PARENT,  
                    )  
                }  
                val predictor = MotionEventPredictor.newInstance(rootView)  
                val touchListener = View.OnTouchListener { view, event ->  
                    // 将来のMotionEventを予測するためにMotionEventPredictorに記録させる
                    predictor.record(event)  
                    val predictedEvent = predictor.predict()  
  
                    try {  
                        when (event.actionMasked) {
                            // 手書き入力の開始
                            // inProgressStrokesViewに入力の開始を伝えています
                            MotionEvent.ACTION_DOWN -> {  
                                view.requestUnbufferedDispatch(event)  
                                val pointerId = event.getPointerId(event.actionIndex)  
                                currentPointerId.value = pointerId  
                                currentStrokeId.value = inProgressStrokesView.startStroke(  
                                    event = event,  
                                    pointerId = pointerId,  
                                    brush = inProgressStrokeBrush  
                                )  
                                true  
                            }  
  
                            // 手書き入力中
                            // inProgressStrokesViewに入力中の軌跡を伝えています
                            MotionEvent.ACTION_MOVE -> {  
                                val pointerId = checkNotNull(currentPointerId.value)  
                                val strokeId = checkNotNull(currentStrokeId.value)  
  
                                for (pointerIndex in 0 until event.pointerCount) {  
                                    if (event.getPointerId(pointerIndex) != pointerId) {  
                                        continue  
                                    }  
                                    inProgressStrokesView.addToStroke(  
                                        event,  
                                        pointerId,  
                                        strokeId,  
                                        predictedEvent  
                                    )  
                                }  
                                true  
                            }  
  
                            // 手書き入力の終了
                            // inProgressStrokesViewに入力の終了を伝えています
                            MotionEvent.ACTION_UP -> {  
                                val pointerId = event.getPointerId(event.actionIndex)  
                                check(pointerId == currentPointerId.value)  
                                val strokeId = checkNotNull(currentStrokeId.value)  
                                inProgressStrokesView.finishStroke(  
                                    event,  
                                    pointerId,  
                                    strokeId  
                                )  
                                view.performClick()  
                                true  
                            }  
  
                            // 手書き入力のキャンセル
                            // inProgressStrokesViewに入力がキャンセルされたことを伝えています
                            MotionEvent.ACTION_CANCEL -> {  
                                val pointerId = event.getPointerId(event.actionIndex)  
                                check(pointerId == currentPointerId.value)  
                                val strokeId = checkNotNull(currentStrokeId.value)  
                                inProgressStrokesView.cancelStroke(strokeId, event)  
                                true  
                            }  
  
                            else -> false  
                        }  
                    } finally {  
                        predictedEvent?.recycle()  
                    }  
                }  
  
                rootView.setOnTouchListener(touchListener)  
                rootView.addView(inProgressStrokesView)  
                rootView  
            },  
        )
        // onStrokesFinishedで受け取った手書き入力された線(Stroke)をCanvasへ描画・反映しています
        Canvas(modifier = Modifier) {  
            val canvasTransform = Matrix()  
            val canvas = drawContext.canvas.nativeCanvas.apply {  
                concat(canvasTransform)  
            }  
            finishedStrokesState.value.forEach { stroke ->  
                strokeRenderer.draw(  
                    stroke = stroke.copy(  
                        stroke.brush.copyWithColorIntArgb(Color.Magenta.toArgb())  
                    ),  
                    canvas = canvas,  
                    strokeToScreenTransform = canvasTransform  
                )  
            }  
        }
    }
}  

// inProgressStrokesViewへの手書き入力が終わったことを検知するためのリスナー
// 手書き入力された線をCanvasへ反映するためにStrokeの保持などを行っています
private val inProgressStrokesFinishListener = object : InProgressStrokesFinishedListener {  
    override fun onStrokesFinished(strokes: Map<InProgressStrokeId, Stroke>) {  
        super.onStrokesFinished(strokes)  
        finishedStrokesState.value += strokes.values  
        inProgressStrokesView.removeFinishedStrokes(strokes.keys)  
    }  
}

挙動はこんな感じです。

さいごに

今回試してみたライブラリの機能がそのまま新機能として検討されるという訳ではありませんが、新しいライブラリやツールなどを利用した新機能をエンジニアから提案していくためには新しいことへの感度を高く持つことや「先ず触ってみる」ことは大事なことですので、プロダクトやサービスの進化につなげるために必要な取り組みとしてご紹介してみました。

CMSというサービスの特性上、機能としてご提供するまでには色々と検討しなければいけないことがありますが、こういった新しいものに触れる取り組みは今後も気軽に実施できると良いなと思っています。

また、ヤプリでは現在エンジニア採用に力を入れております。 ヤプリについて具体的なお話を聞いてみたいと思っていただけましたら、ぜひカジュアル面談にご応募いただければと思います。

open.talentio.com