Closed2

Canvasを使って画面にスタンプを押す

るるすたるるすた

先ほど気づきましたが、タイトルがずっと間違ってた💦

スタンプ用の画像用意

pngでOK。
res>drawableに突っ込む

bitmapを保持する変数宣言

  • context・・・resourcesはそのままでは使えないので、アプリのcontextから取得
  • bitmap・・・BitmapFactoryというAPIを使う
    rememberを使って画像を保持する(毎回デコードさせないため)
    画像の大きさをリサイズして100×100で読み込み
  • stampPosition・・・タップ位置(位置履歴管理のためリストにする)
import

import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import com.rururi.noutore.R
import androidx.core.graphics.scale

@Composable
fun HomeScreen(modifier: Modifier = Modifier) {
    val context = LocalContext.current
    val bitmap = remember {
        BitmapFactory.decodeResource(context.resources, R.drawable.taiyo).scale(320,320) }
    var stampPosition  by remember { mutableStateOf(listOf<Offset>()) } //stampの位置をリストで保持

drawImageでBoxに描画

  • 今回はCanvas使わずBoxに描画させる方法で検討
  • modifierのpointerInputでタップ位置を取得
  • 履歴管理のため位置をリストにどんどん追加
//省略
    Box(
        modifier = modifier
            .fillMaxSize()
            .pointerInput(Unit) {   //入力位置取得
                detectTapGestures { offset ->
                    stampPosition += offset     //タップした位置をリストに追加
                }
            }
    ) { /*ここにイメージを入れる*/}
//省略

スタンプをImageで表示

  • forEachを使ってstampPositionリストを全部Imageとして出力
  • 出力位置はoffsetのintOffsetを使う
    intOffsetとは?offsetの位置をpx単位(dpではない)で表すもの
    offsetはFloatなので、toIntで変換する必要がある。
  • タップした位置がスタンプのちょうど真ん中に来るよう、調整
    //省略
    Box(
        //省略
    ) {
        //スタンプ表示
        stampPosition.forEach { position ->
            Image(
                bitmap = bitmap.asImageBitmap(),
                contentDescription = null,
                modifier = Modifier
                    .offset {   //stampの位置を指定 IntOffset型で渡す
                        IntOffset(
                            (position.x - bitmap.width/2).toInt(),
                            (position.y - bitmap.height/2).toInt()
                        )
                    }
            )
        }
    }
}

次はBottmBar作って、他のスタンプも押せるようにしよう

ボトムバーを作る

ボトムバーを作って画像を3種類、消しゴム代わりの×を追加。
アイコン替わりなので、Imageを使う。
大きさはmodifierで64.dpに固定した。
クリッカブルを設定して。。。。
うーんw コードが長い😱 リストにしてforEachにしよう💦
ViewModelも作っておこう。。。
HomeScreenと同じフォルダにHomeViewModel.ktを作成。

HomeViewModel.kp
//ボトムバーのリストの型用
data class BottomBarType(
    @DrawableRes val icon: Int,
    val label: String,
    val size: Dp,
    val isSelected: Boolean = false,
)
//ボトムバーのリスト
object BottomBarIcons {
    val bottomBarIcons = listOf(
        BottomBarType(
            R.drawable.taiyo,"taiyo",64.dp,true
        ),
        BottomBarType(
            R.drawable.kumo,"kumo",64.dp,false
        ),
        BottomBarType(
            R.drawable.kaminari,"kaminari",64.dp,false
        ),
    )
}

リストを作ったので、BottomBar.ktを新規作成し、コードは以下のようにしました。

  • forEachでImageを3つ並べる
  • Modifierのclickableを空で設定
     クリックはできるけど、アクションしない状態
  • ×アイコン(消しゴムの代わり)はImageではなく、Iconなので、個別に設定
HomeScreen.kt
@Composable
fun BottomBar(modifier: Modifier = Modifier) {
    Row(modifier = modifier.fillMaxWidth()) {
        bottomBarIcons.forEach {
            Image(
                painter = painterResource(id = it.icon),
                contentDescription = it.label,
                modifier = Modifier.size(it.size.dp)
                    .clickable { }
            )
        }
        Icon(
            imageVector = Icons.Default.Clear,
            contentDescription = null,
            modifier = Modifier.size(64.dp)
                .clickable {}
        )
    }
}

ここまでで、一旦実行。
タップすると太陽がポコポコスタンプされて気持ちいい^^
まぁ、太陽しかスタンプできない😅

アイコンを選択でスタンプの種類を変更

次はViewModelで現在選択されているアイコンを管理して、選択中のイメージでスタンプされるよう変更しよう。
viewModelを使うときは依存関係追加しないといけない。
面倒くさいから、デフォルトで入れておいてほしい><

  • ライブラリ入れる
libs.versions.toml
[versions]
lifecycle-viewmodel-compose = "2.8.7"

[libraries]
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle-viewmodel-compose" }
  • gradle設定
build.gradle.kts(Module
dependencies {
    //省略
    //viewmodel
    implementation(libs.androidx.lifecycle.viewmodel.compose)

Sync Now!!!!

よく忘れるので、忘れないように大声で叫びました。

HomeViewModel作成

  • 画面(UI)の現状管理用にHomeUiStateのデータクラスを作成。
    _uiState(ViewModel用)とuiState(UI用)の2つの変数にHlomeUiStateを監視付きで突っ込む。
  • 次に押したスタンプの情報を入れておくStampDataデータクラスも作成。
  • お天気アイコン(画像)をタップしたら、_uiStateのcurrentImageを変更する関数を作成
HomeViewModel.kt
//現在のスタンプ
data class HomeUiState(
    val currentImage: Int = R.drawable.taiyo,
    val stamps: List<StampData> = emptyList()
)

//スタンプ情報
data class StampData(
    val position: Offset,
    @DrawableRes val icon: Int,
)

class HomeViewModel: ViewModel()  {
    private val _uiState = MutableStateFlow(HomeUiState())
    val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()

    //アイコン変更(引数にクリックしたイメージのResIdを渡してもらう)
    fun changeIcon(icon: Int) {
        _uiState.update{
            it.copy(currentImage = icon)
        }
    }
    //スタンプ追加
    fun addStamp(position: Offset) {
        _uiState.update {
            it.copy(stamps = it.stamps + StampData(position, uiState.value.currentImage))
        }
    }
}

BottomBarのお天気アイコンをクリックしたときの処理

  • 引数にviewModelを追加。
    viewModel: HomeViewModel = viewModel()←こいつがエラーになる
    自動的にインポートしてくれないので、自分でインポート💦
    import androidx.lifecycle.viewmodel.compose.viewModel
    なんでインポートしてくれないの?
  • clicableにviewModelのchangeIcon関数を設定。引数は現在選択されているイメージのicon。
BottomBar.kt
@Composable
fun BottomBar(
    modifier: Modifier = Modifier,
+    viewModel: HomeViewModel = viewModel()
) {
    Row(modifier = modifier.fillMaxWidth()) {
        bottomBarIcons.forEach {
            Image(
                painter = painterResource(id = it.icon),
                contentDescription = it.label,
                modifier = Modifier.size(it.size)
+                    .clickable { viewModel.changeIcon(it.icon) }    //アイコンを変更する処理
            )
        }
        //省略

HomeScreen画面をタップしたときの画像描画処理

記事ぐらい長くなってきた(*´Д`)

  • HomeScreenにも引数にviewModel変数を追加
  • viewModelに設定したuiState変数も設定。(画面の状態を保持するため)
  • 元々設定してあったstampPositionの代わりに、StampDataをリストで保持するstamps変数を設定。
    stampPositionは位置しか持っていないけど、stampsは位置とスタンプの種類を持っているので!
  • タップのたびに位置情報とスタンプの種類をstampsに追加。
  • bitmapを最初に読み込んで使いまわしていたけど、タップのたびに呼び出すよう変更
  • 今回の変更は緑で示した部分
HomeScreen
@Composable
fun HomeScreen(
    modifier: Modifier = Modifier,
+    viewModel: HomeViewModel = viewModel()
) {
+    val uiState by viewModel.uiState.collectAsState()
    val context = LocalContext.current
//    val bitmap = remember {
//        BitmapFactory.decodeResource(context.resources, uiState.currentImage).scale(320,320) }

    Box(
        modifier = modifier
            .fillMaxSize()
            .pointerInput(Unit) {   //入力位置取得
                detectTapGestures { offset ->
+                    viewModel.addStamp(offset)   //タップした位置をリストに追加
                }
            }
    ) {
        //スタンプ表示
+        uiState.stamps.forEach { stamp ->
+            val bitmap = BitmapFactory.decodeResource(context.resources, stamp.icon).scale(320,320)
            Image(
                bitmap = bitmap.asImageBitmap(),
                contentDescription = null,
                modifier = Modifier
                    .offset {   //stampの位置を指定 IntOffset型で渡す
                        IntOffset(
                            (stamp.position.x - bitmap.width/2).toInt(),
                            (stamp.position.y - bitmap.height/2).toInt()
                        )
                    }
            )
        }
    }
}
るるすたるるすた

消しゴム実装してない。
うーん。
作るか・・・😂

スタンプを消す機能追加(ViewModel側)

説明はコメントにしました。

HomeViewModel.kt
class HomeViewModel: ViewModel()  {
    //省略

    //アイコン変更(引数にクリックしたイメージのResIdを渡してもらう)
    fun changeIcon(icon: Int) {
        _uiState.update{
+            it.copy(currentImage = icon, isDelete = false)  //isDeleteを追加
        }
    }

    //省略

    //スタンプを消す
    fun deleteStamp(tapPosition: Offset) {
        if(_uiState.value.isDelete) {   //デリートモードの時だけ消す
            _uiState.update {
                val updatedStamps = it.stamps.filterNot { stamp ->  //リストの中から条件に合うものを探して消す
                    val dx = stamp.position.x - tapPosition.x   //タップした位置とスタンプの位置の差分
                    val dy = stamp.position.y - tapPosition.y
                    val distance = sqrt(dx * dx + dy * dy)      //三平方の定理:タップとスタンプの直線距離を計算
                    distance < 64.dp.value                      //直線距離が64dp以下ならlistから削除(タップがスタンプと重なっていると判断)
                }
                it.copy(stamps = updatedStamps)
            }
        }
    }
    //デリートモード(×ボタンを押すとデリートモードになる)
    fun deleteMode() {
        _uiState.update {
            it.copy(isDelete = true)
        }
    }
}

スタンプを消す機能追加(UI側)

  • タップしたときの処理(detectTapGestures)がaddStampだけだったところを、isDaleteがtrueかfalseかによって処理を分岐
    true(デリートモード)のときはdeleteStamp関数を呼ぶ
    false(通常モード)のときはaddStamp関数を呼ぶ
HomeScreen.kt
@Composable
fun HomeScreen(
    modifier: Modifier = Modifier,
    viewModel: HomeViewModel = viewModel()
) {
    val uiState by viewModel.uiState.collectAsState()
    val context = LocalContext.current
    
    Box(
        modifier = modifier
            .fillMaxSize()
            .pointerInput(Unit) {   //入力位置取得
                detectTapGestures { offset ->
+                    if (uiState.isDelete) {
+                        viewModel.deleteStamp(offset)   //タップした位置をリストから削除
+                    } else {
+                        viewModel.addStamp(offset)   //タップした位置をリストに追加
+                    }
                }
            }
    ) {
        //以下全部省略

うーん。。。どの天気アイコンをクリックしたのかわかんないな。

選択中のお天気の背景色を変更

BottomBarTypeの中に無駄にisSelectedとかいう項目があるから、これ使って背景色変えるか。
というわけで、現在選択しているお天気アイコンは背景色を黄色にします!

HomeViewModelの修正

  • HomeUiStateにbottomBarIconsを追加して、ボトムバーアイコンもステータス管理する
  • changeIcon関数にbottomBarIconsを追加して、isSelectedの値を変更したうえで、リストを更新しUIへつなぐ
HomeUiState
//現在のスタンプ
data class HomeUiState(
    val currentImage: Int = R.drawable.taiyo,
    val stamps: List<StampData> = emptyList(),
    val isDelete: Boolean = false,   //×ボタンが押されたらtrue
+    val bottomBarIcons: List<BottomBarType> = BottomBarIcons.bottomBarIcons
)
HomeViewModel.kt
    //アイコン変更(引数にクリックしたイメージのResIdを渡してもらう)
    fun changeIcon(icon: Int) {
        _uiState.update{
+            val updatedIcons = it.bottomBarIcons.map { bottomBarType ->  //ボトムバーのお天気アイコンリスト作り直し
+               bottomBarType.copy(isSelected = bottomBarType.icon == icon)  //アイコンがクリックされたらtrue、それ以外はfalse
            }
            it.copy(
                currentImage = icon,
                isDelete = false,  //isDeleteをfalseに
+                bottomBarIcons = updatedIcons   //ボトムバーのお天気アイコンリストを更新
            )
        }
    }

BottomBarの修正

ボトムバーに選択中のアイコンの背景色を黄色に、ボーダーをつけるという処理を追加
追加部分は緑の部分。

BottomBar.kp
@Composable
fun BottomBar(
    modifier: Modifier = Modifier,
    viewModel: HomeViewModel = viewModel()
) {
+    val uiState by viewModel.uiState.collectAsState()  //bottomBarIconsをuiStateに入れたので追加
    Row(modifier = modifier.fillMaxWidth()) {
+        uiState.bottomBarIcons.forEach {
+            val backgroundColor = if (it.isSelected) Color.Yellow else Color.White
+            val border = if (it.isSelected) 2.dp else 0.dp
            Image(
                painter = painterResource(id = it.icon),
                contentDescription = it.label,
                modifier = Modifier.size(it.size)
                    .clickable { viewModel.changeIcon(it.icon) }
+                    .background(backgroundColor)
+                    .border(border, Color.Black)
            )
        }
    //省略

めちゃくちゃ長くなったけど、いったんここまで!
見直しのため、viewModelとUIの全コードを乗せておく。

UI側のインポート
import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.core.graphics.scale
import androidx.lifecycle.viewmodel.compose.viewModel
UI側
@Composable
fun HomeScreen(
    modifier: Modifier = Modifier,
    viewModel: HomeViewModel = viewModel()
) {
    val uiState by viewModel.uiState.collectAsState()
    val context = LocalContext.current
    val density = LocalDensity.current

    Box(
        modifier = modifier
            .fillMaxSize()
            .pointerInput(Unit) {   //入力位置取得
                detectTapGestures { offset ->
                    if (uiState.isDelete) {
                        val thresholdPx = with(density) { 64.dp.toPx() }   //dpからpxへ変換
                        viewModel.deleteStamp(offset,thresholdPx)   //タップした位置をリストから削除
                    } else {
                        viewModel.addStamp(offset)   //タップした位置をリストに追加
                    }
                }
            }
    ) {
        //スタンプ表示
        uiState.stamps.forEach { stamp ->
            val bitmap = BitmapFactory.decodeResource(context.resources, stamp.icon).scale(320,320)
            Image(
                bitmap = bitmap.asImageBitmap(),
                contentDescription = null,
                modifier = Modifier
                    .offset {   //stampの位置を指定 IntOffset型で渡す
                        IntOffset(
                            (stamp.position.x - bitmap.width/2).toInt(),
                            (stamp.position.y - bitmap.height/2).toInt()
                        )
                    }
            )
        }
    }
}

@Composable
fun BottomBar(
    modifier: Modifier = Modifier,
    viewModel: HomeViewModel = viewModel()
) {
    val uiState by viewModel.uiState.collectAsState()       //bottomBarIconsをuiStateに入れたので追加
    Row(modifier = modifier.fillMaxWidth()) {
        uiState.bottomBarIcons.forEach {
            val backgroundColor = if (it.isSelected) Color.Yellow else Color.White
            val border = if (it.isSelected) 2.dp else 0.dp
            Image(
                painter = painterResource(id = it.icon),
                contentDescription = it.label,
                modifier = Modifier.size(it.size)
                    .clickable { viewModel.changeIcon(it.icon) }
                    .background(backgroundColor)
                    .border(border, Color.Black)
            )
        }
        Icon(
            imageVector = Icons.Default.Clear,
            contentDescription = null,
            modifier = Modifier.size(64.dp)
                .clickable { viewModel.deleteMode()}
        )
    }
}
ViewModel側のインポート
import androidx.annotation.DrawableRes
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import com.rururi.noutore.R
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlin.math.sqrt
ViewModel側
class HomeViewModel: ViewModel()  {
    private val _uiState = MutableStateFlow(HomeUiState())
    val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()

    //アイコン変更(引数にクリックしたイメージのResIdを渡してもらう)
    fun changeIcon(icon: Int) {
        _uiState.update{
            val updatedIcons = it.bottomBarIcons.map { bottomBarType ->     //ボトムバーのお天気アイコンリスト作り直し
                bottomBarType.copy(isSelected = bottomBarType.icon == icon) //アイコンがクリックされたらtrue、それ以外はfalse
            }
            it.copy(
                currentImage = icon,
                isDelete = false,  //isDeleteをfalseに
                bottomBarIcons = updatedIcons     //ボトムバーのお天気アイコンリストを更新
            )
        }
    }
    //スタンプ追加
    fun addStamp(position: Offset) {
        _uiState.update {
            it.copy(
                stamps = it.stamps + StampData(position, uiState.value.currentImage)
            )
        }
    }

    //スタンプを消す
    fun deleteStamp(tapPosition: Offset,threshold: Float = 64f) {   //uiからタップの位置と閾値をもらう
        if(_uiState.value.isDelete) {   //デリートモードの時だけ消す
            _uiState.update {
                val updatedStamps = it.stamps.filterNot { stamp ->  //リストの中から条件に合うものを探して消す
                    val dx = stamp.position.x - tapPosition.x   //タップした位置とスタンプの位置の差分
                    val dy = stamp.position.y - tapPosition.y
                    val distance = sqrt(dx * dx + dy * dy)      //三平方の定理:タップとスタンプの直線距離を計算
                    distance < threshold                      //直線距離が64dp以下ならlistから削除(タップがスタンプと重なっていると判断)
                }
                it.copy(stamps = updatedStamps)
            }
        }
    }
    //デリートモード
    fun deleteMode() {
        _uiState.update {
            it.copy(isDelete = true)
        }
    }
}

//スタンプ情報
data class StampData(
    val position: Offset,
    @DrawableRes val icon: Int,
)

//現在のスタンプ
data class HomeUiState(
    val currentImage: Int = R.drawable.taiyo,
    val stamps: List<StampData> = emptyList(),
    val isDelete: Boolean = false,   //×ボタンが押されたらtrue
    val bottomBarIcons: List<BottomBarType> = BottomBarIcons.bottomBarIcons
)

data class BottomBarType(
    @DrawableRes val icon: Int,
    val label: String,
    val size: Dp,
    val isSelected: Boolean = false,
)
object BottomBarIcons {
    val bottomBarIcons = listOf(
        BottomBarType(
            R.drawable.taiyo,"taiyo",64.dp,true
        ),
        BottomBarType(
            R.drawable.kumo,"kumo",64.dp,false
        ),
        BottomBarType(
            R.drawable.kaminari,"kaminari",64.dp,false
        ),
    )
}
このスクラップは5ヶ月前にクローズされました