🔥

Canvasを使った手書き機能

に公開

UIの基本部分

UI作っていきます。

  • Canvas作って、Boxでラップ
  • Boxのmodifierを領域いっぱいまで広げ、背景色を黄色、枠線も引いておく
    背景色と枠線は手書きできる場所を明確にするための処置なので、あとで消すかも。
  • Canvasも領域いっぱいまで広げておく
@Composable
fun NumberCanvas(modifier: Modifier = Modifier) {
    Box(
        modifier=modifier
            .fillMaxSize()
            .background(Color.Yellow)
            .border(2.dp, Color.Black)
    ) {
        Canvas(modifier = Modifier.fillMaxSize()) {

        }
    }
}

Boxにジェスチャー追加

ModifierのpointerInputでジェスチャーは設定できる。
今回はドラッグをしたときに線を描画させたいので、detactDragGesturesを使う。
ジェスチャーの中のonDrag系のイベントは一旦全部空にしておく。
ViewModel作ってから設定。

NumberCanvas
    //省略
    Box(
        modifier=modifier
            .fillMaxSize()
            .background(Color.Yellow)
            .border(2.dp, Color.Black)
+            .pointerInput(Unit) {
+                detectDragGestures(
+                    onDragStart = { },
+                    onDrag = { },
+                    onDragEnd = { },
+                    onDragCancel = { },
+                )
            }
    ) {
//省略

ViewModelを作る

めちゃめちゃしんどかった。
全然うまくいかなくてキーってなった😵‍💫

PathDataデータクラス作成

描画する線を管理しよう!とはりきって作りましたが・・・。

PathData
//描画する線のパス、色、太さ
data class PathData(
    val path: Path = Path(),
    val color: Color = Color.Black,
    val strokeWidth: Float = 24f
)

色と線の太さなんて固定だし、pathがあればいいだけだった。
データクラスなんていらんじゃんってなった。
でも、作っちゃったし、これありきでコーディングしたし。
つらかったからもう変えたくない。

CalcViewModelクラス作成→変数設定

各変数の意味はコメント参照
rawPathsだけ、PathData(いらないデータクラス)にせず、直接Offset型にしました。
色とか変えたくなったらどうしよう💦

CalcViewModel
class CalcViewModel:ViewModel() {
    private val _paths = mutableStateListOf<PathData>() //確定した線の一覧(リスト)
    val paths: List<PathData> get()= _paths             //UI公開用
    private var rawPaths = mutableListOf<Offset>() //描画中の線の一覧(リスト)まだ未確定
    var currentPath by mutableStateOf(PathData())    //現在の線
    //省略

パスの再構築

最初はドラッグ中のパスをcurrentPathで管理していたのだけど、うまくいかなくて、Path()を新しく作り直すして、currentPathに突っ込むという関数を作ることになった。

CalcViewModel
    private fun rebuildPath() {
        val path= Path().apply {
            if(rawPaths.isNotEmpty()){
                moveTo(rawPaths.first().x, rawPaths.first().y)  //最初の点
                for (i in 1 until rawPaths.size) {              //リストの要素数処理
                    lineTo(rawPaths[i].x, rawPaths[i].y)        //前回の点と現在の点を線する
                }
            }
        }
        currentPath = PathData(
            path = path,        //新しいPath()インスタンスを生成→再コンポーズする
            color = currentPath.color,
            strokeWidth = currentPath.strokeWidth
        )
    }

ドラッグ開始時の処理

CalcViewModel
    fun onDragStart(offset: Offset){
        rawPaths.clear()        //描画中だったパスのリストをクリア
        rawPaths.add(offset)    //新しい線をリストに追加
        rebuildPath()           //パスの再構築(moveToが描画)
    }

ドラッグ中の処理

CalcViewModel
    //ドラッグ中のパスの更新
    fun onDrag(offset: Offset){
        rawPaths.add(offset)    //描画中の線をリストにどんどん追加
        rebuildPath()           //パスの再構築(lineToにより線が伸びていく)
    }

ドラッグ終了時の処理

CalcViewModel
    fun onDragEnd() {
        _paths.add(currentPath) //描いた線をどんどん確定リストに追加
        rawPaths.clear()        //必要がなくなったドロップ中リストをクリア
        currentPath = PathData()    //現在の線もクリア
    }
CalcViewModel.ktのコード
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.lifecycle.ViewModel

class CalcViewModel:ViewModel() {
    private val _paths = mutableStateListOf<PathData>() //確定した線の一覧(リスト)
    val paths: List<PathData> get()= _paths             //UI公開用
    private var rawPaths = mutableListOf<Offset>() //描画中の線の一覧(リスト)まだ未確定
    var currentPath by mutableStateOf(PathData())    //現在の線

    //ドラッグ開始時のパス
    fun onDragStart(offset: Offset){
        rawPaths.clear()        //描画中だったパスのリストをクリア
        rawPaths.add(offset)    //新しい線をリストに追加
        rebuildPath()           //パスの再構築(moveToが描画)
    }

    //ドラッグ中のパスの更新
    fun onDrag(offset: Offset){
        rawPaths.add(offset)    //描画中の線をリストにどんどん追加
        rebuildPath()           //パスの再構築(lineToにより線が伸びていく)
        Log.d("calk", "onDrag: ${currentPath.path}")
    }

    //ドラッグが終わった時のパスのリストへの追加
    fun onDragEnd() {
        _paths.add(currentPath) //描いた線をどんどん確定リストに追加
        rawPaths.clear()        //必要がなくなったドロップ中リストをクリア
        currentPath = PathData()    //現在の線もクリア
    }

    //パスの再構築
    private fun rebuildPath() {
        val path= Path().apply {
            if(rawPaths.isNotEmpty()){
                moveTo(rawPaths.first().x, rawPaths.first().y)  //最初の点
                for (i in 1 until rawPaths.size) {              //リストの要素数処理
                    lineTo(rawPaths[i].x, rawPaths[i].y)        //前回の点と現在の点を線する
                }
            }
        }
        currentPath = PathData(
            path = path,        //新しいPath()インスタンスを生成→再コンポーズする
            color = currentPath.color,
            strokeWidth = currentPath.strokeWidth
        )
    }
}

//描画する線のパス、色、太さ
data class PathData(
    val path: Path = Path(),
    val color: Color = Color.Black,
    val strokeWidth: Float = 24f
)

UIとviewModelをつなぐ

ジェスチャーを完成させる

NumberCanvasコンポーザブル関数に、CalcViewModelへの参照を追加し

  • 各変数(viewModel、paths、currentPath)の宣言
    viewModelだけおまじないのようにNumberCalcの引数に入れてます。
  • onDrag系のイベントはViewModelの同名関数と、それに伴う引数をセット
    onDragはchangeとammountがあるのですが、changeしか使いません
NumberCanvas
@Composable
fun NumberCanvas(
    modifier: Modifier = Modifier,
+    viewModel: CalcViewModel = viewModel(),
) {
+    val paths = viewModel.paths
+    val currentPath = viewModel.currentPath

    Box(
        modifier=modifier
            .fillMaxSize()
            .background(Color.Yellow)
            .border(2.dp, Color.Black)
            .pointerInput(Unit) {
                detectDragGestures(
+                   onDragStart = { offset ->
+                       viewModel.onDragStart(offset)
+                   },
+                   onDrag = { change, _ ->
+                       viewModel.onDrag(change.position)
+                   },
+                   onDragEnd = { viewModel.onDragEnd() },
+                   onDragCancel = { },
+               )
            }
    ) {
//省略

Canvasに描画部分をいれる

ドラッグ中にリアルタイムで描画させる部分と、ドラッグが終わった時に一気に描画させる部分の2つを追加します。

  • ドラッグ中の描画
    →現在のパスで線を描画
  • ドラッグが終わってからの描画
    →今までのパスを集めたリストから線を描画
NumberCanvas
//省略
        Canvas(modifier = Modifier.fillMaxSize()) {
            //ドラッグ中のパスの描画
            drawPath(
                path = currentPath.path,
                color = currentPath.color,
                style = Stroke(currentPath.strokeWidth, cap = StrokeCap.Round)
            )
            //確定したパスの描画
            paths.forEach { path ->
                drawPath(
                    path = path.path,
                    color = path.color,
                    style = Stroke(path.strokeWidth, cap = StrokeCap.Round)
                )
            }
        }

実行結果

Discussion