たくさんの自由帳

Jetpack Compose でテキストや画像のドラッグアンドドロップと Android 14

投稿日 : | 0 日前

文字数(だいたい) : 8890

どうもこんばんわ。
パイセンの犯人捕まったそうですね。全然関係ない枠で知った。

Imgur

(テレビにパイセン映し出されたの普通に面白すぎる)

ニコ生でパイセンの放送何回か見たことあるけど、
パイセンでもちゃんと警察動いててなんか凄いなーって(ひどい言い方だよ)

そんなパイセンを忘れないようにここに書いておくことにします。 → すまん前科が多すぎて忘れられそうにない。いつ狙われてもおかしくないような人だったし、、

2018 年ってもう 6 年も前になるの・・・えぇ
懐かしすぎる、

Imgur

ニコ生年齢あんけ in パイセンの枠
2018 年くらいらしい

Imgur

2020 年にニコ生戻ってきて?やってたそうだけど、生前どこでやってたかはわからん。
FC2とかもBANとかなんとか。

Imgur

本題

それとこれとは関係ないのですが、
(決してパイセンがドラッグやって捕まったからドラッグアンドドロップに繋がったわけではない)

Jetpack Composeでテキストとかファイルの、ドラッグアンドドロップ機能がつけられるようになったみたいなので、
試してみることにします。

ドラッグアンドドロップ自体はAndroid 7くらいからありますが、
Android 14 でパワーアップしたのでそれも紹介。あんまり盛り上がってなくて悲しい。

環境

なまえあたい
Android StudioAndroid Studio Jellyfish 2023.3.1 Patch 1
Jetpack Compose2024.05.00
minSdk24 (ドラッグアンドドロップが Android 7 以降?)

ドキュメント

テキストをドラッグアンドドロップで貼り付けるとかは、そんなに難しくない。
画像とか、バイナリデータをやり取りしたい場合は一気に面倒になる。

テキストをやり取りしてみる

まずは簡単な、テキストをやり取りしてみることにします。

共通レイアウト

送信と受信が両方試せるように、ドラッグアンドドロップの開始、終了をそれぞれ置きました。
処理はこのあと書きます

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen() {
    Scaffold(
        topBar = {
            TopAppBar(title = { Text(text = "ドラッグアンドドロップ") })
        }
    ) { paddingValues ->
 
        Column(modifier = Modifier.padding(paddingValues)) {
 
            DragAndDropSendContainer(
                modifier = Modifier
                    .padding(10.dp)
                    .fillMaxWidth()
            )
 
            DragAndDropReceiveContainer(
                modifier = Modifier
                    .padding(10.dp)
                    .fillMaxWidth()
            )
 
        }
    }
}
 
@Composable
private fun DragAndDropSendContainer(modifier: Modifier = Modifier) {
    val inputText = remember { mutableStateOf("") }
 
    OutlinedCard(modifier = modifier) {
        Column(modifier = Modifier.padding(10.dp)) {
 
            Text(text = "ドラッグアンドドロップ 送信側")
 
            OutlinedTextField(
                value = inputText.value,
                onValueChange = { inputText.value = it }
            )
 
            Box(
                modifier = Modifier
                    .size(100.dp)
                    .border(1.dp, MaterialTheme.colorScheme.primary),
                contentAlignment = Alignment.Center
            ) {
                Text(text = "長押し!")
            }
        }
    }
}
 
@Composable
private fun DragAndDropReceiveContainer(modifier: Modifier = Modifier) {
    val receiveText = remember { mutableStateOf("") }
 
    OutlinedCard(modifier = modifier) {
        Column(modifier = Modifier.padding(10.dp)) {
 
            Text(text = "ドラッグアンドドロップ 受信側")
 
            Box(
                modifier = Modifier
                    .size(200.dp)
                    .border(1.dp, MaterialTheme.colorScheme.primary),
                contentAlignment = Alignment.Center
            ) {
                Text(text = "ここに持ってくる")
            }
 
            HorizontalDivider()
 
            Text(text = receiveText.value)
        }
    }
}

送信側

ClipDataにドラッグアンドドロップで送りたいデータを詰め込みます。
あとは、ドラッグアンドドロップしたいBox()とかのコンポーネントのModifierへ、dragAndDropSource { }することで、長押し時に移動できるようになります。

ClipData.newPlainTextの第1引数、"Text"の部分、ドキュメントにはユーザーへ表示する値とかなんとか書いてありますが、
私もそんなUI見たこと無いので、おそらく開発側が自由に決めていい値のはずです。

Box(
    modifier = Modifier
        .size(100.dp)
        .border(1.dp, MaterialTheme.colorScheme.primary)
        .dragAndDropSource {
            detectTapGestures(onLongPress = {
                // value に文字をいれる
                val clipData = ClipData.newPlainText("Text", inputText.value)
                startTransfer(DragAndDropTransferData(clipData = clipData, flags = View.DRAG_FLAG_GLOBAL))
            })
        },
    contentAlignment = Alignment.Center
) {
    Text(text = "長押し!")
}

受信側

まずはドラッグアンドドロップの受信コールバックを用意します。
ドラッグアンドドロップの操作が開始した、領域の中に入った、出た、ドラッグアンドドロップが終了した。等を知ることが出来ます。

ドラッグアンドドロップで投げ込まれたときのコールバックは必須で、残りの開始とか終了とかは自由に。
今回はUI側に反映させたいので、コールバックで受け取ることにします。

val isProgressDragAndDrop = remember { mutableStateOf(false) }
val callback = remember {
    object : DragAndDropTarget {
        override fun onDrop(event: DragAndDropEvent): Boolean {
            // TODO この後すぐ!
            return true
        }
        override fun onStarted(event: DragAndDropEvent) {
            super.onStarted(event)
            isProgressDragAndDrop.value = true
        }
        override fun onEnded(event: DragAndDropEvent) {
            super.onEnded(event)
            isProgressDragAndDrop.value = false
        }
    }
}

次に、ドラッグアンドドロップを受信したいコンポーネントのModifierで、dragAndDropTargetを呼び出して、受け取ることを示します。
引数ですが、受け取れるデータの種類(MIME-Typeとか見れる)と、さっき作ったコールバックです。

Box(
    modifier = Modifier
        .size(200.dp)
        .border(1.dp, MaterialTheme.colorScheme.primary)
        .background(
            // ドラッグアンドドロップ操作中はコンテナの背景色を変化
            color = if (isProgressDragAndDrop.value) {
                MaterialTheme.colorScheme.primary.copy(0.5f)
            } else {
                Color.Transparent
            }
        )
        .dragAndDropTarget(
            shouldStartDragAndDrop = { event ->
                // 受け取れる種類。とりあえずテキスト
                event
                    .mimeTypes()
                    .contains(ClipDescription.MIMETYPE_TEXT_PLAIN)
            },
            target = callback
        ),
    contentAlignment = Alignment.Center
) {
    Text(text = "ここに持ってくる")
}

最後に、ドラッグアンドドロップで投げ込まれたデータのパースをします。
適当に、最初のClipDataを取り出して、テキストとして表示するようにしてみます。

ClipDataの詳細(MIME-Typeとかは)ClipDescriptionに入っています。
マルチプラットフォームを意識しているのか、toAndroidDragEvent()で取り出す必要があります。

val receiveText = remember { mutableStateOf("") }
val isProgressDragAndDrop = remember { mutableStateOf(false) }
val callback = remember {
    object : DragAndDropTarget {
        override fun onDrop(event: DragAndDropEvent): Boolean {
            val androidEvent = event.toAndroidDragEvent()
            // 最初のデータを取り出します
            val mimeType = androidEvent.clipDescription.getMimeType(0)
            val text = androidEvent.clipData.getItemAt(0).text
            receiveText.value = """
                受信したデータ
                MIME-Type: $mimeType
                text: $text
            """.trimIndent()
            return true
        }
        override fun onStarted(event: DragAndDropEvent) {
            super.onStarted(event)
            isProgressDragAndDrop.value = true
        }
        override fun onEnded(event: DragAndDropEvent) {
            super.onEnded(event)
            isProgressDragAndDrop.value = false
        }
    }
}

使ってみる

テキストボックスに適当に文字を入れて、「長押し!」を押すとドラッグアンドドロップが始まって、
それと同時に、受信先のコンポーネントの背景色が変化して、受信先で指を離すと中身が表示されるはずです。どうだろ???

Imgur

また、アプリを超えても利用できることがこれで分かるはず。
おんなじアプリを2つ作って、アプリを超えてドラッグアンドドロップしてみましたが、これもちゃんと動きます。

Imgur

もちろん、ドラッグアンドドロップに対応したアプリへの送信、受信も出来ます。

Imgur

Android 14 要素どこ????

https://9to5google.com/2023/05/19/android-14-drag-and-drop/

iOSにはすでにあったそうですが、Androidにも来ました。
ドラッグアンドドロップでアプリを超えたい場合、マルチウィンドウ(画面分割)する必要がなくなりました。

ドラッグアンドドロップ中でも、その指を離さなければ、別の指でホーム画面に戻って別アプリを起動したり、アプリを切り替えたり出来るようになりました。
そしてドラッグアンドドロップ中の指を離せばそのアプリに貼り付けられます。

画面分割するほど画面が大きくない場合に便利そう。
もちろんコード上でなにかする必要はありません。

Imgur

画像もやり取りしたい

画像を入れる場合、ClipDataへ画像は入れられず、バイナリを共有するための仕組みを使い、Uriを発行してもらい、そのUriClipDataへ入れる必要があります。
画像のファイルパスを入れれば良いとか、そういう話ではないのでかなり面倒くさい。

大雑把にこんな感じ。
AndroidIntentのサイズ上限が出来たあたり(私は知らない)で、このFileProviderの知見が結構あるので助かる。。。

Imgur

FileProvider

https://developer.android.com/reference/androidx/core/content/FileProvider

アプリの固有ストレージ内のファイル(Context#getExternalFileDir()内のファイルとか)をUriを経由して外部へ公開できるやつ。
ContentProviderをいい感じに実装してくれたものになります。ので、頑張ればContentProviderでも同じことが出来るはず??(何もわからない)

データ送信側

  • FileProviderが存在することをAndroidManifest.xmlに書く
  • 画像とかのバイナリデータを、Context#getExternalFileDir()とかContext#getFilesDir()に保存する
    • getExternalFileDir/sdcard/Android/data/{アプリケーションID}
      • こっちは、パソコンに繋いだときにエクスプローラーからアクセスできちゃいます
      • Android StudioDevice Explorer機能でも
      • あとAndroid標準ファイラー(com.android.documentsui)でも見れます
        • Files by Googleのことではありません
    • getFilesDir/data/data/{アプリケーションID}
      • こっちはパソコンに繋いでも見れません
      • デバッグビルド中であれば見れるかも?
      • それ以外の場合はrootを取らない限り見れません
  • そのファイルパスをFileProviderへ登録する
    • と、Uriがもらえる
  • そのUriClipDataに入れる
  • ドラッグアンドドロップする

データ受信側

  • ClipDataからUriを取り出します
  • ActivityCompat.requestDragAndDropPermissionsを呼び出します
    • これを呼び出さないと、Uriへアクセスできません
  • これでUriが使えるようになります
  • 後は画像表示で使えばおけ。CoilとかGlideUriを渡せば表示できるのでは無いでしょうか!?!?
    • 今回はライブラリ入れるまでもないので、自分でBitmapFactoryUriからBitmapを作ります...
    • 使えるなら画像読み込みライブラリ(CoilGlideを使うべきです)。面倒事を全部やってくれるので他のことに集中できます。

画像のドラッグアンドドロップを作る

ちなみに、画像とか言っていますが別に画像じゃなくても任意のバイナリをやり取りできるはず。

画像を送る側

送る側はFileProviderの用意が必要なので面倒。

FileProvider を作る

まずはres/xmlの中に、file_path.xmlを作ります。
多分名前は何でもいいんですけど、ドキュメント通りに行こうと思います。

Imgur

中身です。
<external-files-pathContext#getExternalFileDir()の中にあるファイルを共有するためですね。
Context#getFilesDir()の場合は<files-pathにする必要があります。

https://developer.android.com/reference/androidx/core/content/FileProvider

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <external-files-path name="images" path="images/"/>
</paths>

次に、FileProviderを継承したクラスを作ります。
コンストラクタの引数はさっき作ったxmlです。

import androidx.core.content.FileProvider
 
class DragAndDropFileProvider : FileProvider(R.xml.file_paths)

最後に、AndroidManifestFileProviderを登録してFileProvider 編は終了。

<provider
    android:name=".DragAndDropFileProvider"
    android:authorities="${applicationId}.provider"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_paths" />
</provider>

画像の用意と送信側のレイアウトを作る

レイアウトを作ります。

あと、画像を用意するのが面倒なので、自身のアプリのアイコンをBitmapで取って、ファイルに保存することにしようと思います。
自身のアイコン画像の取得はこんな感じです。

@Composable
private fun ImageDragAndDropSendContainer(modifier: Modifier = Modifier) {
    val context = LocalContext.current
 
    // 自分のアプリのアイコン
    val bitmap = remember { mutableStateOf<Bitmap?>(null) }
    LaunchedEffect(key1 = Unit) {
        // Bitmap を取り出す
        val iconDrawable = context.packageManager.getApplicationIcon(context.packageName)
        bitmap.value = iconDrawable.toBitmap()
    }
 
    OutlinedCard(modifier = modifier) {
        Column(modifier = Modifier.padding(10.dp)) {
 
            Text(text = "画像ドラッグアンドドロップ 送信側")
 
            // 画像を表示、ドラッグアンドドロップも兼ねて
            if (bitmap.value != null) {
                Image(
                    modifier = Modifier,
                    bitmap = bitmap.value!!.asImageBitmap(),
                    contentDescription = null
                )
            }
        }
    }
}

MainScreen()に設置するのも忘れないでね。

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen() {
    Scaffold(
        topBar = {
            TopAppBar(title = { Text(text = "ドラッグアンドドロップ") })
        }
    ) { paddingValues ->
 
        Column(
            modifier = Modifier
                .padding(paddingValues)
                .verticalScroll(rememberScrollState()) // スクロールしたい
        ) {
 
            // 省略...
 
            HorizontalDivider()
 
            // 今作ったやつ
            ImageDragAndDropSendContainer(
                modifier = Modifier
                    .padding(10.dp)
                    .fillMaxWidth()
            )
        }
    }
}

どうでしょう?

Imgur

Uri を取得する

次はBitmapgetExternalFileDirに保存して、Uriを取得します。
先述のとおり、getExternalFileDirは、見ようと思えば見れるフォルダなので、もしまずいようならgetFilesDirを選んでください。(xmlも直してね)

Bitmapの保存先をgetExternalFileDirの中に作ります。
/imagesフォルダを先に作ってますが、これはfile_paths.xmlpath=""imagesにしたからですね。

getUriForFileUriが出来ます。エラーの場合は例外が投げられます。

// 自分のアプリのアイコン
val bitmap = remember { mutableStateOf<Bitmap?>(null) }
// 共有で使える Uri
val shareUri = remember { mutableStateOf<Uri?>(null) }
 
LaunchedEffect(key1 = Unit) {
    // Bitmap を取り出す
    val iconDrawable = context.packageManager.getApplicationIcon(context.packageName)
    bitmap.value = iconDrawable.toBitmap()
 
    // ファイル getExternalFilesDir の中に作って保存する
    // images フォルダの images は、 file_paths.xml の path="" が images だからです。
    val imageFolder = context.getExternalFilesDir(null)!!.resolve("images").apply { mkdir() }
    val imageFile = imageFolder.resolve("${System.currentTimeMillis()}.png").apply { createNewFile() }
    imageFile.outputStream().use { outputStream ->
        bitmap.value!!.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
    }
 
    // FileProvider に登録して Uri を取得
    shareUri.value = FileProvider.getUriForFile(context, "${context.packageName}.provider", imageFile)
    // 生成される Uri はこんな感じ
    // content://io.github.takusan23.jetpackcomposefiledraganddrop.provider/images/1716833782696.png
}

画像のドラッグアンドドロップをする

テキストと同じ用にdragAndDropSourceを呼び出して、ドラッグアンドドロップで掴めるようにします。
テキストとは違い、newUriを使って、Uriを入れたClipDataを作ります。MIME-TypeとかはUriを使ってMediaStoreに問い合わせて自動でセットしてくれるらしい。

それから、flagsですが、Uriに読み取り権限を付与するためのView.DRAG_FLAG_GLOBAL_URI_READフラグを立てておく必要があります。
FileProviderのドキュメントにはIntentの場合しか書かれてませんが、ClipDataの場合も権限を付与しないと、ドラッグアンドドロップ先で読み取りできません。

他にも書き込み権限とかあります。Viewのドキュメント参照。
Viewのドキュメントクソ重いので開くときは注意→ https://developer.android.com/reference/android/view/View#DRAG_FLAG_GLOBAL_URI_READ

Image(
    modifier = Modifier.dragAndDropSource {
        detectTapGestures(onLongPress = {
            // ClipData へ Uri を
            val nonnullUri = shareUri.value ?: return@detectTapGestures
            val clipData = ClipData.newUri(context.contentResolver, "image_uri", nonnullUri)
            startTransfer(
                DragAndDropTransferData(
                    clipData = clipData,
                    flags = View.DRAG_FLAG_GLOBAL or View.DRAG_FLAG_GLOBAL_URI_READ // Uri 読み取り可能ですよのフラグを or で立てておく。ビット演算
                )
            )
        })
    },
    bitmap = bitmap.value!!.asImageBitmap(),
    contentDescription = null
)

これで、送信側は完成のはずです。
Google Keepとかにドラッグアンドドロップ出来るはず!!!!!できた!?!?!?!?

Imgur

Imgur

画像を受け取る側

こっちはいくらか簡単です。
受け取る側くらいは考えてもいいんじゃないでしょうか?

mimeTypes()のチェックは、画像用に直す必要があります。これは画像以外のバイナリ(動画とか)の場合もそうですが。
今回は画像だけなので、MIME-Typeimage/で始まっているかを見ています。

Uriを受け取った後、ActivityCompat.requestDragAndDropPermissionsを呼び出す必要があります。
これを呼び出さないと、Uriを使って画像へアクセスしようとしても、ブロックされます。Uriが用済みになったら、release()してあげましょう。

requestDragAndDropPermissionsした後はUriを使ってアクセス出来るようになるのでcontext.contentResolver.openInputStreamでデータを読み出して、
Bitmapにしています。先述のとおり、GlideCoilのライブラリが使えるなら使うべきです。今回はこのためだけにわざわざ入れないですが。。。

@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ImageDragAndDropReceiveContainer(modifier: Modifier = Modifier) {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()
 
    // 受け取った画像
    val receiveBitmap = remember { mutableStateOf<Bitmap?>(null) }
    val isProgressDragAndDrop = remember { mutableStateOf(false) }
    val callback = remember {
        object : DragAndDropTarget {
            override fun onDrop(event: DragAndDropEvent): Boolean {
                val androidEvent = event.toAndroidDragEvent()
 
                // DragAndDropPermission を作らないと、Uri を使ったアクセスが出来ません
                val dragAndDropPermissions = ActivityCompat.requestDragAndDropPermissions(context as Activity, androidEvent)
                // 最初のデータを取り出します
                val receiveUri = androidEvent.clipData.getItemAt(0).uri
 
                // UI 処理なので一応コルーチンで
                scope.launch(Dispatchers.IO) {
                    // Uri から Bitmap を作る
                    // Glide や Coil が使えるなら使うべきです
                    receiveBitmap.value = context.contentResolver.openInputStream(receiveUri)
                        ?.use { inputStream -> BitmapFactory.decodeStream(inputStream) }
                    // とじる
                    dragAndDropPermissions?.release()
                }
                return true
            }
 
            override fun onStarted(event: DragAndDropEvent) {
                super.onStarted(event)
                isProgressDragAndDrop.value = true
            }
 
            override fun onEnded(event: DragAndDropEvent) {
                super.onEnded(event)
                isProgressDragAndDrop.value = false
            }
        }
    }
 
    OutlinedCard(modifier = modifier) {
        Column(modifier = Modifier.padding(10.dp)) {
 
            Text(text = "画像ドラッグアンドドロップ 受信側")
 
            // ドラッグアンドドロップを待ち受ける
            Box(
                modifier = Modifier
                    .size(300.dp)
                    .border(1.dp, MaterialTheme.colorScheme.primary)
                    .background(
                        // ドラッグアンドドロップ操作中はコンテナの背景色を変化
                        color = if (isProgressDragAndDrop.value) {
                            MaterialTheme.colorScheme.primary.copy(0.5f)
                        } else {
                            Color.Transparent
                        }
                    )
                    .dragAndDropTarget(
                        shouldStartDragAndDrop = { event ->
                            // 受け取れる種類。とりあえず
                            val supportedMimeTypePrefix = "image/"
                            event
                                .mimeTypes()
                                .all { receiveMimeType -> receiveMimeType.startsWith(supportedMimeTypePrefix) }
                        },
                        target = callback
                    )
            ) {
                // 画像表示
                if (receiveBitmap.value != null) {
                    Image(
                        bitmap = receiveBitmap.value!!.asImageBitmap(),
                        contentDescription = null
                    )
                }
            }
        }
    }
}

これをMainScreen()に置いて完成!

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen() {
    Scaffold(
        topBar = {
            TopAppBar(title = { Text(text = "ドラッグアンドドロップ") })
        }
    ) { paddingValues ->
 
        Column(
            modifier = Modifier
                .padding(paddingValues)
                .verticalScroll(rememberScrollState())
        ) {
 
            // 省略...
 
            ImageDragAndDropSendContainer(
                modifier = Modifier
                    .padding(10.dp)
                    .fillMaxWidth()
            )
 
            ImageDragAndDropReceiveContainer(
                modifier = Modifier
                    .padding(10.dp)
                    .fillMaxWidth()
            )
        }
    }
}

どうだろ???
画像も受け取れるアプリが出来ましたか???

Imgur

そーすこーど

https://github.com/takusan23/JetpackComposeFileDragAndDrop

参考にしました

ありざいす