Upgrade to Pro — share decks privately, control downloads, hide ads and more …

ComposeでTimeRangePickerを作る_YUMEMI.grow Mobile #2

kako351
April 12, 2023

ComposeでTimeRangePickerを作る_YUMEMI.grow Mobile #2

kako351

April 12, 2023
Tweet

More Decks by kako351

Other Decks in Technology

Transcript

  1. Composeで
    TimeRangePickerを作る
    YUMEMI.grow Mobile #2
    kako351@おいしい健康

    View full-size slide

  2. 自己紹介
    kako351 / @kako_351
    おいしい健康 Androidエンジニア
    Androidエンジニア募集中!
    仕事
    ● バイク(ハンターカブ)
    ● ギター
    ● コーヒー自宅焙煎
    趣味

    View full-size slide

  3. What’s new おいしい健康

    View full-size slide

  4. 食事記録機能をリリースしました! 🎉
    時計部分はJetpack ComposeのCanvasで書かれていま
    す。(私ではなくて同僚が実装してくれました...!)
    これをみてCanvas面白そうだなと思ったのが今回の話す内容の
    きっかけです。
    What’s new おいしい健康

    View full-size slide

  5. Jetpack ComposeでTimeRangePickerを
    作れるようになる
    TimeRangePickerとは
    TimeRangePickerは、ユーザーが時間範囲を選
    択できるようにするUIコンポーネントです。開
    始時間と終了時間の間で選択された時間範囲を
    表します。
    本日のゴール

    View full-size slide

  6. 本日話す内容
    ● 弧を描く
    ● 時計盤を作る
    ● ドラッグ操作を制御する
    ● ドラッグ操作で時刻を設定する
    ● 2つの時刻を繋ぐ弧を描く
    ● https://developer.android.com/ 

    ● https://github.com/Droppers/TimeRangePicker 

    ● https://ja.wikipedia.org/wiki/%E4%B8%89%E8%A7%9
    2%E9%96%A2%E6%95%B0 

    アジェンダ 参考

    View full-size slide

  7. 弧を描く

    View full-size slide

  8. Canvas(
    //...
    ) {
    drawArc(
    color = Color.LightGray,
    startAngle = 0f,
    sweepAngle = 360f,
    useCenter = false,
    style = Stroke(width = 50f),
    )

    View full-size slide

  9. Canvas(
    //...
    ) {
    drawArc(
    color = Color.LightGray,
    startAngle = 0f,
    sweepAngle = 360f,
    useCenter = false,
    style = Stroke(width = 50f),
    )
    弧を描くには drawArcを利用
    します

    View full-size slide

  10. ● drawArc
    ● drawCircle
    ● drawOval
    ● drawLine
    ● drawRect
    draw〇〇
    ● drawRoundRect
    ● drawPath
    ● drawPoint
    ● drawImage

    View full-size slide

  11. 時計盤を作る

    View full-size slide

  12. for (hour in 0..23) {
    val centerX = size.width / 2
    val centerY = size.height / 2
    val radius = (size.width / 2) - 50f
    val lineLength = 20f
    val angle = hour * (360 / 24) // 360度を24分割
    val radian = Math.toRadians(angle.toDouble())
    val startX = (centerX + radius * Math.cos(radian)).toFloat()
    val startY = (centerY + radius * Math.sin(radian)).toFloat()
    val endX = (centerX + (radius - lineLength) * Math.cos(radian)).toFloat()
    val endY = (centerY + (radius - lineLength) * Math.sin(radian)).toFloat()
    drawLine(
    color = Color.Black,
    start = Offset(x = startX, y = startY),
    end = Offset(x = endX, y = endY),
    cap = StrokeCap.Round,
    strokeWidth = 3f
    )
    }

    View full-size slide

  13. for (hour in 0..23) {
    val centerX = size.width / 2
    val centerY = size.height / 2
    val radius = (size.width / 2) - 50f
    val lineLength = 20f
    val angle = hour * (360 / 24) // 360度を24分割
    24時間計の時計盤にするため
    0時から23時まで表します
    毎時刻の角度を求めます

    View full-size slide

  14. val radian = Math.toRadians(angle.toDouble())
    val startX = (centerX + radius * Math.cos(radian)).toFloat()
    val startY = (centerY + radius * Math.sin(radian)).toFloat()
    val endX = (centerX + (radius - lineLength) * Math.cos(radian)).toFloat()
    val endY = (centerY + (radius - lineLength) * Math.sin(radian)).toFloat()
    角度からX Y座標を求めます
    孤の中心を囲むように配置するため
    にcanterX, centerYを足しています

    View full-size slide

  15. drawLine(
    color = Color.Black,
    start = Offset(x = startX, y = startY),
    end = Offset(x = endX, y = endY),
    cap = StrokeCap.Round,
    strokeWidth = 3f
    )
    線を引く開始位置の座標と終了位置
    の座標を渡して線を描画します。

    View full-size slide

  16. if (hour % 6 == 0) {
    drawIntoCanvas {
    it.nativeCanvas.drawText(
    "$hour",
    endX,
    endY + lineLength,
    Paint().apply {
    color = Color.Black.toArgb()
    textSize = 45f
    textAlign = Paint.Align.CENTER
    }
    )
    }
    } else {
    時計盤ぽくするために、6の倍数時
    刻の時はその時間をテキストで表示
    してみます。

    View full-size slide

  17. if (hour % 6 == 0) {
    drawIntoCanvas {
    it.nativeCanvas.drawText(
    "$hour",
    endX,
    endY + lineLength,
    Paint().apply {
    color = Color.Black.toArgb()
    textSize = 45f
    textAlign = Paint.Align.CENTER
    }
    )
    }
    } else {
    Canvas内でテキストを表示するには
    drawIntoCanvasの中で
    nativeCanvas.drawText() を呼び出す
    必要があります。

    View full-size slide

  18. if (hour % 6 == 0) {
    drawIntoCanvas {
    it.nativeCanvas.drawText(
    "$hour",
    endX,
    endY + lineLength,
    Paint().apply {
    color = Color.Black.toArgb()
    textSize = 45f
    textAlign = Paint.Align.CENTER
    }
    )
    }
    } else {
    時刻が右中央から始まってしまいま
    す。
    中央上に配置したい。

    View full-size slide

  19. val angle = hour * (360 / 24) // 360度を24分割
    val radian = Math.toRadians(angle.toDouble())
    val startX = (centerX + radius * Math.cos(radian)).toFloat()
    val startY = (centerY + radius * Math.sin(radian)).toFloat()
    中央 (x:0 y:0)
    cosθ = 1
    sinθ = 0
    0時 = 0度(angle = 0)と
    考えると...
    中央座標からの0度の座
    標を指定しているので
    右中央に配置されます
    が右中

    View full-size slide

  20. val angle = hour * (360 / 24)// 360度を24分割
    val radian = Math.toRadians(angle.toDouble()) - (PI / 2)
    val startX = (centerX + radius * Math.cos(radian)).toFloat()
    val startY = (centerY + radius * Math.sin(radian)).toFloat()
    0時を中央上に配置する
    場合、反時計周りに90
    度回転すればよい
    angleから90度を引けば
    いい
    PI = 180度 / 2 を引きま

    View full-size slide

  21. ドラッグ操作を
    制御する

    View full-size slide

  22. var dragOffsetX by remember { mutableStateOf(0f) }
    var dragOffsetY by remember { mutableStateOf(0f) }
    Canvas(
    modifier = Modifier
    .pointerInput(Unit) {
    detectDragGestures(
    // …
    onDrag = { change, dragAmount ->
    change.consume()
    dragOffsetX = change.position.x
    dragOffsetY = change.position.y
    }
    )
    },
    ) {
    drawCircle(
    color = Color.Blue,
    radius = 100f,
    center = Offset(x = dragOffsetX, y = dragOffsetY)
    )

    View full-size slide

  23. Canvas(
    modifier = Modifier
    .pointerInput(Unit) {
    detectDragGestures(
    // …
    onDrag = { change, dragAmount ->
    change.consume()
    dragOffsetX = change.position.x
    dragOffsetY = change.position.y
    }
    )
    },
    Canvasでジェスチャーを検知したい
    時は.pointerInputを呼び出します

    View full-size slide

  24. Canvas(
    modifier = Modifier
    .pointerInput(Unit) {
    detectDragGestures(
    // …
    onDrag = { change, dragAmount ->
    change.consume()
    dragOffsetX = change.position.x
    dragOffsetY = change.position.y
    }
    )
    },
    双方向のドラッグ操作を制御したい
    時はdetectDragGesturesを呼び出し
    ます

    View full-size slide

  25. ・draggable修飾子
    縦方向のみのドラッグや横方向のみのドラッグなど単一方向のドラッグ操作を制
    御したい時にはdraggable修飾子で十分です。
    ・pointerInput修飾子 ( + detectDragGestures)
    縦横斜めなど、ドラッグ操作全体を制御したい時はpointerInput修飾子の中で
    detectDragGesturesを呼び出します。
    単一方向ドラッグと双方向ドラッグ

    View full-size slide

  26. ドラッグ操作で
    時刻を設定する

    View full-size slide

  27. var centerX by remember { mutableStateOf(0f) }
    var centerY by remember { mutableStateOf(0f) }
    var dragOffsetX by remember { mutableStateOf(0f) }
    var dragOffsetY by remember { mutableStateOf(0f) }
    // xy座標からAngleを決める
    val dragAngle = remember(key1 = dragOffsetX, key2 = dragOffsetY) {
    derivedStateOf {
    Math.toDegrees(
    Math.atan2(
    (dragOffsetY - centerY).toDouble(),
    (dragOffsetX - centerX).toDouble()
    )
    ).toFloat()
    }
    }
    ドラッグ中の座標から角度を求める

    View full-size slide

  28. var centerX by remember { mutableStateOf(0f) }
    var centerY by remember { mutableStateOf(0f) }
    Canvas(
    modifier = Modifier
    .onSizeChanged {
    centerX = it.width / 2f
    centerY = it.height / 2f
    }
    Canvasのサイズを取得する
    onSizeChangedでComposable外に
    Comsableのサイズを渡します。
    サイズ取得の方法はいくつかありますが、今回
    はonSizeChangedがニーズに合っていました

    View full-size slide

  29. val timeX = remember(key1 = dragAngle.value, key2 = centerX) {
    derivedStateOf {
    val radius = centerX
    val angle = Math.toRadians(dragAngle.value.toDouble())
    (radius * Math.cos(angle)).toFloat() + centerX
    }
    }
    val timeY = remember(key1 = dragAngle.value, key2 = centerY) {
    derivedStateOf {
    val radius = centerY
    val angle = Math.toRadians(dragAngle.value.toDouble())
    (radius * Math.sin(angle)).toFloat() + centerY
    }
    }
    ドラッグ中の角度から弧上の座標を求める

    View full-size slide

  30. drawCircle(
    color = Color.Blue,
    radius = 25f,
    center = Offset(x = timeX.value, y = timeY.value),
    )
    座標に合わせて円を描画する

    View full-size slide

  31. val time = remember(key1 = dragAngle.value) {
    derivedStateOf {
    var angle = dragAngle.value + Math.toDegrees(PI / 2)
    if (angle < 0) angle += 360f
    if (angle >= 360f) angle -= 360f
    val hour = (angle / 15).toInt()
    val minute = ((angle % 15) / 15 * 60).toInt()
    "%02d:%02d".format(hour, minute)
    }
    }
    ドラッグ中の角度から時刻を求める

    View full-size slide

  32. 2つの時刻を繋ぐ
    弧を描く

    View full-size slide

  33. var endTimeDragOffsetX by remember { mutableStateOf(0f) }
    var endTimeDragOffsetY by remember { mutableStateOf(0f) }
    // xy座標からAngleを決める
    val endTimeDragAngle = remember(key1 = endTimeDragOffsetX, key2 =
    endTimeDragOffsetY) {
    derivedStateOf {
    Math.toDegrees(
    Math.atan2(
    (endTimeDragOffsetY - centerY).toDouble(),
    (endTimeDragOffsetX - centerX).toDouble()
    )
    ).toFloat()
    }
    }
    終了時刻分の角度を求める

    View full-size slide

  34. val startAngle = startTimeDragAngle.value
    val sweepAngle = when(endTimeDragAngle.value < startTimeDragAngle.value) {
    true -> endTimeDragAngle.value - startTimeDragAngle.value + 360f
    false -> endTimeDragAngle.value - startTimeDragAngle.value
    }
    drawArc(
    color = Color.Blue,
    startAngle = startAngle,
    sweepAngle = sweepAngle,
    useCenter = false,
    style = Stroke(
    width = 50f,
    cap = StrokeCap.Round
    ),
    )
    ドラッグ操作した角度に合わせて弧を描画する

    View full-size slide

  35. val startAngle = startTimeDragAngle.value
    val sweepAngle = when(endTimeDragAngle.value < startTimeDragAngle.value) {
    true -> endTimeDragAngle.value - startTimeDragAngle.value + 360f
    false -> endTimeDragAngle.value - startTimeDragAngle.value
    }
    drawArc(
    color = Color.Blue,
    startAngle = startAngle,
    sweepAngle = sweepAngle,
    useCenter = false,
    style = Stroke(
    width = 50f,
    cap = StrokeCap.Round
    ),
    )
    ドラッグ操作した角度に合わせて弧を描画する
    drawArcはstartAngleとsweepAngleの
    間を時計回りに描画します。
    数字の小さい方から大きい方へ

    View full-size slide

  36. val startAngle = startTimeDragAngle.value
    val sweepAngle = when(endTimeDragAngle.value < startTimeDragAngle.value) {
    true -> endTimeDragAngle.value - startTimeDragAngle.value + 360f
    false -> endTimeDragAngle.value - startTimeDragAngle.value
    }
    drawArc(
    color = Color.Blue,
    startAngle = startAngle,
    sweepAngle = sweepAngle,
    useCenter = false,
    style = Stroke(
    width = 50f,
    cap = StrokeCap.Round
    ),
    )
    ドラッグ操作した角度に合わせて弧を描画する
    開始位置が終了位置より大きくなる
    ときは360度足します

    View full-size slide

  37. https://github.com/kako351/Compose-TimeRangePicker
    Githubに公開しています

    View full-size slide

  38. ● Canvasコンポーザブル内ではdrawArc, drawCircle,drawLineなどで図形や線を
    描画できます
    ● Canvas内にテキストを表示したい場合は、drawIntoCanvas内でdrawTextを使
    います
    ● Canvas上のドラッグ操作を検知するならpointerInput修飾子内で
    detectDragGesturesを呼び出します
    まとめ

    View full-size slide

  39. ご静聴
    ありがとう
    ございました
    39
    YUMEMI.grow Mobile #2 2023/04/12

    kako351@おいしい健康


    View full-size slide