こんにちは、Androidグループに所属しているにゃふんた(nyafunta9858)です。 今回は、なかなか試せていなかったAndroidXの新しいライブラリを弊社のYappdateDayを使って触ってみましたので、そちらについてお話してみたいと思います。
YappdateDayについて
YappdateDayでは普段どういったことに取り組んでいるのか?といったところは過去の記事にまとまっておりますので、よろしければ合わせてお読みください。
times.yappli.co.jp tech.yappli.io
YappdateDayは普段ではなかなか着手できていない軽微な不具合や改善、技術的負債の返済活動など、ヤプリの掲げているValueのひとつ「再構築」にフォーカスする日となっています。 本来であれば再構築へフォーカスしたり新機能開発へのファーストステップとできるとベストなのですが、新機能を提案するにもアイディアの種を仕込んでいくのも大事かと思いますので、今回はファーストステップを踏むための数手前の段階として新しいものにサクッと触れてみようという魂胆のもと触れてみました。
今回試したライブラリ
さて、今回試してみましたのは、今秋にアルファ版がリリースされたAndroidXのInk APIです。
Ink APIはInking(手書き入力)機能を提供するAndroidXのライブラリで、Android アプリケーションへの手書き入力機能の導入を簡単にしてくれます。
また、Ink APIは機能ごとにモジュールで提供されているため、必要なものを選んで利用することが出来ます。 今回は基本的な手書き入力部分をひとまず触って感触を掴みたいと思っていたので、Geometryモジュールを使った消しゴム機能を作ってみるといったことはスコープから外しています。
手書き入力を試す、その前に
今回は次の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") }
実際に手書き入力を試してみた
今回試した中では以下の流れで手書き入力を画面へ描画しています。
- 手書き入力を受け付けるViewのセットアップ
MotionEvent.ACTION_DOWN
受信後、手書き入力の開始をAPIへ通知MotionEvent.ACTION_MOVE
受信後、手書き入力の更新をAPIへ通知MotionEvent.ACTION_UP
受信後、手書き入力の終了をAPIへ通知- Canvasへ描画する線をを保持するため、手書き入力の終了を検知するリスナーを登録
- Canvasへ 5.で保持した手書き入力の軌跡を描画
1. 手書き入力を受け付けるViewのセットアップ
まずは手書き入力した軌跡を表示するためのView
、InProgressStrokesView
を用意して、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
を呼び出しています。currentPointerId
、currentStrokeId
を保持していますが、これらはInProgressStrokesView
にStroke
の状態更新を依頼する際に必要になるためです。この後にも度々出てきます。
また、View.requestUnbufferedDispatch
を呼び出すことでInProgressStrokesView
がMotionEvent.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へ描画する線をを保持するため、手書き入力の終了を検知するリスナーを登録
処理の流れとしてはここまでで手書き入力された軌跡が画面に表示されるようになっているかと思いますが、手書き入力を終えると入力していた軌跡が残らずに消えてしまいます。
あくまで手書き入力している間の軌跡を表示するだけですので、入力が完了したらそれまで入力していた情報を使って描画する必要があります。
そこで入力完了を検知するためのリスナー、InProgressStrokesFinishedListener
をInProgressStrokesView
に追加して、手書き入力が完了した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
を描画します。
Stroke
のCanvas
への描画には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) } }
挙動はこんな感じです。
![](https://cdn-ak.f.st-hatena.com/images/fotolife/n/nyafunta9858/20250107/20250107160659.gif)
さいごに
今回試してみたライブラリの機能がそのまま新機能として検討されるという訳ではありませんが、新しいライブラリやツールなどを利用した新機能をエンジニアから提案していくためには新しいことへの感度を高く持つことや「先ず触ってみる」ことは大事なことですので、プロダクトやサービスの進化につなげるために必要な取り組みとしてご紹介してみました。
CMSというサービスの特性上、機能としてご提供するまでには色々と検討しなければいけないことがありますが、こういった新しいものに触れる取り組みは今後も気軽に実施できると良いなと思っています。
また、ヤプリでは現在エンジニア採用に力を入れております。 ヤプリについて具体的なお話を聞いてみたいと思っていただけましたら、ぜひカジュアル面談にご応募いただければと思います。