Android 版の Jetpack Compose に独自のテキスト選択メニューを実装する

# 概要
Jetpack Compose を使って UI を実装すると、テキスト選択時のメニューが基本的なものしか使えないので追加したくなります。

![Screenshot (2024_06_09 19 59 07).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102004/b94b3262-e6dd-2a67-995e-38b94300d453.png)

EditText のようにテキストを選択して何かしらの操作をするメニューを実装したい場合、
Android 版の Jetpack Compose では Desktop 版のようなインターフェイスは用意されていないので、別の方法で実装する必要があります。

調べたところ、TextToolbar を継承したクラスに ComposableView を渡し、従来の View の startActionMode を呼び出すことで実現できそうでした。
表示するメニューの項目は XML で定義します。
なお、これをやるとデフォルトの cut / copy / paste / select all のメニューが出なくなるので、別途自分で呼び出すよう実装する必要があります。
(メニューIDの追加と呼び出しだけで可、実際の動きは引数のコールバックを使うだけでいい)

# 実装
以下の3ステップで実装可能です。

1. メニューリソースの定義
2. TextToolbar を継承したカスタムの TextToolbar を実装
3. CustomTextToolbar を LocalTextToolbar に provide

## 1. メニューリソースの定義
従来の ActionMode で使っていたものがあれば、それをそのまま使えます。res/menu 以下に置いてください。

```xml:res/menu/context_menu.xml
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">

    <item
        android:id="@+id/context_copy"
        android:title="Copy"
        android:orderInCategory="100"
        />

    <item
        android:id="@+id/context_cut"
        android:title="Cut"
        android:orderInCategory="100"
        />

    <item
        android:id="@+id/context_paste"
        android:title="Paste"
        android:orderInCategory="100"
        />

    <item
        android:id="@+id/context_select_all"
        android:title="Select all"
        android:orderInCategory="100"
        />

    <item
        android:id="@+id/context_other_custom_menu"
        android:title="Other custom menu"
        android:orderInCategory="100"
        />

</menu>
```

## 2. TextToolbar を継承したカスタムの TextToolbar を実装
引数で ComposableView を渡してもらいます。holder は重複表示させないための制御に使います。

```kotlin
class CustomTextToolbar(private val view: View) : TextToolbar {

    /**
     * 重複表示の制御で利用
     */
    private val holder = AtomicReference<ActionMode?>(null)

    /**
     * Composable 関数の内部処理を動かすために必要
     */
    override val status: TextToolbarStatus
        get() = if (holder.get() == null) TextToolbarStatus.Hidden else TextToolbarStatus.Shown

    override fun hide() {
        val actionMode = holder.get() ?: return
        actionMode.finish()
        holder.set(null)
    }

    override fun showMenu(
        rect: Rect,
        onCopyRequested: (() -> Unit)?,
        onPasteRequested: (() -> Unit)?,
        onCutRequested: (() -> Unit)?,
        onSelectAllRequested: (() -> Unit)?
    ) {
        val current = holder.get()
        if (current != null) {
            return
        }

        val callback = object : ActionMode.Callback {

            override fun onCreateActionMode(actionMode: ActionMode?, menu: Menu?): Boolean {
                MenuInflater(view.context).inflate(R.menu.context_menu, menu)
                return true
            }

            override fun onActionItemClicked(actionMode: ActionMode?, menu: MenuItem?): Boolean {
                val handled = invokeMenuAction(menu?.itemId ?: -1,)
                if (handled) {
                    actionMode?.finish()
                    hide()
                }
                return handled
            }

            override fun onPrepareActionMode(p0: ActionMode?, p1: Menu?) = false

            override fun onDestroyActionMode(p0: ActionMode?) {
                p0?.hide(0)
                holder.set(null)
            }

            private fun invokeMenuAction(itemId: Int, context: Context): Boolean {
                return when (itemId) {
                    R.id.context_copy -> {
                        onCopyRequested?.invoke()
                        true
                    }
                    R.id.context_paste -> {
                        onPasteRequested?.invoke()
                        true
                    }
                    R.id.context_cut -> {
                        onCutRequested?.invoke()
                        true
                    }
                    R.id.context_select_all -> {
                        onSelectAllRequested?.invoke()
                        true
                    }
                    R.id.context_other_custom_menu -> {
                        // Write action...
                        true
                    }
                    // and so on...
                    else -> false
                }
            }

        }

        val actionMode = view.startActionMode(callback, ActionMode.TYPE_FLOATING) // Floating にしない場合は上部にバーが出る
        holder.set(actionMode)
    }

}
```

## 3. CustomTextToolbar を LocalTextToolbar に provide
この TextToolbar を以下のように設定してください。

```kotlin
    CompositionLocalProvider(
        LocalTextToolbar provides CustomTextToolbar(LocalView.current) // <- ここ
    ) {
        BasicTextField(
```

![Screenshot_20240609-200200_Yobidashi.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102004/621f745f-dfc5-5760-84e8-035b66d5f65d.jpeg)

TextField に限らず、様々な Composable で導入可能です。

## 注意
EditText は特別に挿入時と選択時で別々の ActionModeCallback を設定することができました。

- customInsertionActionModeCallback
- customSelectionActionModeCallback

EditText の代替として TextField 等を使っていて、この方法でカスタムのメニューを追加したい時、
上記のようなコールバックの使い分けはできないため、TextFieldValue の状態を見て Inflate するメニューを切り替える、というやり方で実現できます。

```kotlin
val callback = object : ActionMode.Callback {

    override fun onCreateActionMode(actionMode: ActionMode?, menu: Menu?): Boolean {
        val context = view.context
        val menuInflater = MenuInflater(context)

        val selectedText = textFieldValue.getSelectedText().text
        if (selectedText.isNotEmpty()) {
            menuInflater.inflate(R.menu.context_selected, menu) // テキストを選択している時だけ使うメニューを別途 Inflate
        }

        menuInflater.inflate(R.menu.context_menu, menu)
```

# 参考

- [android - Floating toolbar for text selection Jetpack Compose - Stack Overflow](https://stackoverflow.com/questions/68956792/floating-toolbar-for-text-selection-jetpack-compose)
- [android - How to disable copy/paste/cut in a TextField Jetpack Compose? - Stack Overflow](https://stackoverflow.com/questions/70518908/how-to-disable-copy-paste-cut-in-a-textfield-jetpack-compose)

---
# TextField 以外の Composable で選択されたテキストを使う(あまりお勧めできない方法)
Compose の TextField の場合は State が選択されたテキストを保持しているので特に問題とはならないのですが、
SelectionContainer の中にある Text 等の文字列を取得するのは 1.6.0 の時点だと非常に困難です。選択されたテキストに関する API がことごとく internal で隠されています。

SelectionContainer の実装を見に行くとそれらしきコールバックがあるのですが、

```kotlin
//  Copyright 2021 The Android Open Source Project

@Composable
fun SelectionContainer(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
    var selection by remember { mutableStateOf<Selection?>(null) }
    SelectionContainer(
        modifier = modifier,
        selection = selection,
        onSelectionChange = {
            selection = it
        },
        children = content
    )
}
```

一般の開発者は使うことができません。

```kotlin
//  Copyright 2021 The Android Open Source Project

@Suppress("ComposableLambdaParameterNaming")
@Composable
internal fun SelectionContainer(
    /** A [Modifier] for SelectionContainer. */
    modifier: Modifier = Modifier,
    /** Current Selection status.*/
    selection: Selection?,
    /** A function containing customized behaviour when selection changes. */
    onSelectionChange: (Selection?) -> Unit,
    children: @Composable () -> Unit
) {
```

あまりおすすめできる方法ではありませんが、TextToolbar.showMenu の onCopyRequested コールバックを使うやり方を紹介します。

1. ClipboardManager から現在の Primary Clip のテキストを取得して保持
1. TextToolbar.showMenu の onCopyRequested を実行して選択されたテキストを取得
1. 1のテキストを ClipboardManager の Primary Clip に再セット

コードは以下の通りです。

```kotlin
class CommonMenuActionCallback(private val context: Context) : MenuActionCallback {

    override fun invoke(
        menuId: Int,
        onCopyRequested: (() -> Unit)?,
        onPasteRequested: (() -> Unit)?,
        onCutRequested: (() -> Unit)?,
        onSelectAllRequested: (() -> Unit)?
    ): Boolean = when (menuId) {
        // ...

        R.id.web_search -> {
            val text = extractSelectedTextWithDirtyAccess(onCopyRequested)
            if (text != null && text.isNotBlank()) {
                // text を使った処理
            }
            true
        }


    private fun extractSelectedTextWithDirtyAccess(onCopyRequested: (() -> Unit)?): CharSequence? {
        val clipboardManager = clipboardManager(context)
        val present = clipboardManager?.primaryClip

        onCopyRequested?.invoke()
        val primary = clipboardManager?.primaryClip

        clipboardManager?.setPrimaryClip(
            if (present?.getItemAt(0)?.text != null) present
            else ClipData.newPlainText("", "")
        )
        return primary?.getItemAt(0)?.text
    }
```

用いる際は自己の責任においてお願いします。
Clipboard に入るのはテキストだけとは限らないこと、Clipboard は別のアプリも共通して使うものであることを考えると非常にダーティーなコードです。
前者に関しては primaryClip の ClipData をそのまま保持してセットし直せばよいかと思うかもしれませんが、それをやると SecurityException がスローされます。
まあ、それができると好き勝手に画像をセットする行儀の悪いアプリが作れてしまうので、当然と言えば当然な気がします。

選択されたテキストを使える API が公開されるのを待つか、 SelectionContainer の内部の実装を見てフォークするか、その2つが妥当な選択肢だと私は思います。

----

# おまけ
Desktop 版の Jetpack Compose では以下の通りやると独自のメニューを実装できます。

```kotlin
import androidx.compose.foundation.ContextMenuArea
import androidx.compose.foundation.ContextMenuItem
import androidx.compose.foundation.ContextMenuState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.text.TextContextMenu
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalLocalization

class TextContextMenuFactory {

    @OptIn(ExperimentalFoundationApi::class)
    operator fun invoke(): TextContextMenu {
        return object : TextContextMenu {
            @Composable
            override fun Area(
                textManager: TextContextMenu.TextManager,
                state: ContextMenuState,
                content: @Composable () -> Unit
            ) {
                val localization = LocalLocalization.current

                val itemConsumer = {
                    val items = mutableListOf<ContextMenuItem>()
                    val cut = textManager.cut
                    if (cut != null) {
                        items.add(ContextMenuItem(localization.cut, cut))
                    }
                    val copy = textManager.copy
                    if (copy != null) {
                        items.add(ContextMenuItem(localization.copy, copy))
                    }
                    val paste = textManager.paste
                    if (paste != null) {
                        items.add(ContextMenuItem(localization.paste, paste))
                    }
                    val selectAll = textManager.selectAll
                    if (selectAll != null) {
                        items.add(ContextMenuItem(localization.selectAll, selectAll))
                    }
                    // 以下は独自のメニュー
                    items.add(
                        ContextMenuItem("Search") {
                            // Implement action.
                        }
                    )
                    items.add(
                        ContextMenuItem("Count") {
                            // Implement action.
                        }
                    )

                    items
                }

                ContextMenuArea(itemConsumer, state, content = content)
            }
        }
    }

}
```

上記の Factory を使って生成される TextContextMenu を LocalTextContextMenu に渡せば OK です。

```kotlin
CompositionLocalProvider(
    LocalTextContextMenu provides TextContextMenuFactory().invoke()
) {
    SomeComponent()
}
```