👀

【雑記】TextToolbarを他のComposeにそれっぽく寄せた書き方で動かしてみた

2025/03/12に公開

AndroidJetpackComposeでのTextToolbarは取り回しが悪い

現状ではCompositionLocalProviderを使った方法しか提供されていないため、TextToolbarを継承したクラスを別に用意する必要がある。
ネット上を漁ってみても、xmlレイアウトリソースに依存していたり、都度カスタムクラスを用意しているような例しかなく取り回しが悪いように思えた。
なので宣言的っぽく実装できるようにしてみる。

CompositionLocalProvider(
    LocalTextToolbar provides CustomTextToolbar
) {
    /* contents */
}

それっぽく実装してみる

TextFieldValueでいろいろするので拡張関数を定義してます
fun TextFieldValue.isSelectedAll(): Boolean {
    return selection.min == 0 && selection.max == text.length
}

fun TextFieldValue.isNotSelectedAll(): Boolean {
    return !isSelectedAll()
}

fun TextFieldValue.isSelectedNone(): Boolean {
    return selection.min == selection.max
}

fun TextFieldValue.isNotSelectedNone(): Boolean {
    return !isSelectedNone()
}

fun TextFieldValue.getTextBeforeSelection(): AnnotatedString {
    return getTextBeforeSelection(selection.min)
}

fun TextFieldValue.getTextAfterSelection(): AnnotatedString {
    return getTextAfterSelection(text.length - selection.max)
}

fun TextFieldValue.insertText(text: AnnotatedString): TextFieldValue {
    val newText = buildAnnotatedString {
        append(getTextBeforeSelection())
        append(text)
        append(getTextAfterSelection())
    }
    val newCursor = selection.min + text.text.length
    return this.copy(annotatedString = newText, selection = TextRange(newCursor, newCursor))
}

fun TextFieldValue.removeSelectedText(): TextFieldValue {
    // return if empty
    if (isSelectedNone()) return this

    val newText = buildAnnotatedString {
        append(getTextBeforeSelection())
        append(getTextAfterSelection())
    }
    return this.copy(annotatedString = newText, selection = TextRange(selection.min, selection.min))
}

fun TextFieldValue.selectAll(): TextFieldValue = this.copy(selection = TextRange(0, text.length))

使い回せるTextToolbarクラスを作る

動的にメニュー項目を生成していく感じのTextToolbarを用意する。
Compose側で状態の管理を行うため、使い回す事ができる。
拡張していく場合もクラスの実装はこれだけでOK(のはず)。

@Composable
fun rememberDynamicTextToolbar(): DynamicTextToolbar {
    val view = LocalView.current
    return remember { DynamicTextToolbar(view = view) }
}

@Stable
class DynamicTextToolbar(
    private val view: View,
) : TextToolbar {

    private var actionMode: ActionMode? = null
    private val menuActions = emptyMap<Int, TextToolbarAction>().toMutableMap()

    fun addAction(
        title: String,
        action: () -> Unit,
        order: Int = Menu.NONE
    ) {
        menuActions.put(View.generateViewId(), TextToolbarAction(title, action, order))
        actionMode?.invalidate()
    }

    fun clearActions() {
        menuActions.clear()
        actionMode?.invalidate()
    }

    override val status: TextToolbarStatus
        get() = if (actionMode == null) TextToolbarStatus.Hidden else TextToolbarStatus.Shown

    // 引数のコールバックは使用しない
    override fun showMenu(
        rect: Rect,
        onCopyRequested: (() -> Unit)?,
        onPasteRequested: (() -> Unit)?,
        onCutRequested: (() -> Unit)?,
        onSelectAllRequested: (() -> Unit)?
    ) {
        // return if already shown
        if (actionMode != null) return

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            val callback = object : ActionMode.Callback2() {
                override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {

                    menuActions.forEach { (id, action) ->
                        menu.add(Menu.NONE, id, action.order, action.title)
                    }
                    return true
                }
                override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
                    menu.clear()
                    menuActions.forEach { (id, action) ->
                        menu.add(Menu.NONE, id, action.order, action.title)
                    }
                    return true
                }
                override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
                    menuActions[item.itemId]?.onAction?.invoke()
                    mode.finish()
                    return true
                }
                override fun onDestroyActionMode(mode: ActionMode) {
                    mode.hide(0)
                    actionMode = null
                }
                override fun onGetContentRect(mode: ActionMode, view: View, outRect: android.graphics.Rect) {
                    outRect.set(
                        rect.left.toInt(),
                        rect.top.toInt(),
                        rect.right.toInt(),
                        rect.bottom.toInt()
                    )
                }
            }
            actionMode = view.startActionMode(callback, ActionMode.TYPE_FLOATING)
        } else {
            val callback = object : ActionMode.Callback {
                override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {

                    menuActions.forEach { (id, action) ->
                        menu.add(Menu.NONE, id, action.order, action.title)
                    }
                    return true
                }

                override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
                    menu.clear()
                    menuActions.forEach { (id, action) ->
                        menu.add(Menu.NONE, id, action.order, action.title)
                    }
                    return true
                }

                override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
                    menuActions[item.itemId]?.onAction?.invoke()
                    mode.finish()
                    return true
                }

                override fun onDestroyActionMode(mode: ActionMode) {
                    actionMode = null
                }
            }
            view.startActionMode(callback)
        }
    }

    override fun hide() {
        actionMode?.finish()
        actionMode = null
    }
}

private data class TextToolbarAction(
    val title: String,
    val onAction: () -> Unit,
    val order: Int
)

CompositionLocalProviderごとラップしたComposeable関数を用意

LaunchedEffectの中でメニューを管理している感じ。
実際に使う際は、各タイトルは引数だったり文字リソースから取得するといいと思う。

@Composable
fun CustomTextToolbar(
    value: TextFieldValue,
    onValueChange: (TextFieldValue) -> Unit, // コールバックで元のテキストに反映する
    content: @Composable () -> Unit
) {
    val clipboardManager = LocalClipboardManager.current
    val textToolbar = rememberDynamicTextToolbar()

    // この中で状態に合わせたメニュー項目を生成する
    // 実質的なメニューの実装部分
    LaunchedEffect(value) {
        // リセット
        textToolbar.clearActions()

        // 選択状態や文字列に合わせて表示したいメニューを追加していく
        if (value.isNotSelectedNone()) {
            textToolbar.addAction(
                title = "Cut",
                action = {
                    val selectedText = value.getSelectedText()
                    clipboardManager.setText(selectedText)
                    onValueChange(value.removeSelectedText())
                }
            )

            textToolbar.addAction(
                title = "Copy",
                action = {
                    val selectedText = value.getSelectedText()
                    clipboardManager.setText(selectedText)
                }
            )
        }

        textToolbar.addAction(
            title = "Paste",
            action = {
                val clipText = clipboardManager.getText() ?: return@addAction
                onValueChange(value.insertText(clipText))
            }
        )

        if (value.isNotSelectedAll()) {
            textToolbar.addAction(
                title = "Select All",
                action = { onValueChange(value.selectAll()) }
            )
        }
    }

    CompositionLocalProvider(
        LocalTextToolbar provides textToolbar,
        content = content
    )
}

使うとき

こんな感じに呼び出す。
TextFieldごとラップした関数を用意して使うと見やすくなるはず。

@Composable
fun ToolbarTest() {

    // 両方に渡される    
    var text by remember { mutableStateOf(TextFieldValue("abcdefg")) }
    
    CustomTextToolbar(
        value = text,
        onValueChange = { text = it },
    ) {
        TextField(
            value = text,
            onValueChange = { text = it }
        )
    }
}
ラップした関数の例
@Composable
fun TextFieldWithCustomToolbar(
    value: TextFieldValue,
    onValueChange: (TextFieldValue) -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    readOnly: Boolean = false,
    textStyle: TextStyle = LocalTextStyle.current,
    label: @Composable (() -> Unit)? = null,
    placeholder: @Composable (() -> Unit)? = null,
    leadingIcon: @Composable (() -> Unit)? = null,
    trailingIcon: @Composable (() -> Unit)? = null,
    prefix: @Composable (() -> Unit)? = null,
    suffix: @Composable (() -> Unit)? = null,
    supportingText: @Composable (() -> Unit)? = null,
    isError: Boolean = false,
    visualTransformation: VisualTransformation = VisualTransformation.None,
    keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
    keyboardActions: KeyboardActions = KeyboardActions.Default,
    singleLine: Boolean = false,
    maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
    minLines: Int = 1,
    interactionSource: MutableInteractionSource? = null,
    shape: Shape = TextFieldDefaults.shape,
    colors: TextFieldColors = TextFieldDefaults.colors()
) {
    CustomTextToolbar(
        value = value,
        onValueChange = onValueChange
    ) {
        TextField(
            value = value,
            onValueChange = onValueChange,
            modifier = modifier,
            enabled = enabled,
            readOnly = readOnly,
            textStyle = textStyle,
            label = label,
            placeholder = placeholder,
            leadingIcon = leadingIcon,
            trailingIcon = trailingIcon,
            prefix = prefix,
            suffix = suffix, 
            supportingText = supportingText,
            isError = isError, 
            visualTransformation = visualTransformation, 
            keyboardOptions = keyboardOptions, 
            keyboardActions = keyboardActions, 
            singleLine = singleLine, 
            maxLines = maxLines, 
            minLines = minLines,
            interactionSource = interactionSource,
            shape = shape, 
            colors = colors
        )
    }
}

(おまけ)実装例

よく使いそうな例置いておきます

デフォルトのやつ
@Immutable
object DynamicTextToolbarDefaults {
    object Title {
        const val COPY = "Copy"
        const val PASTE = "Paste"
        const val CUT = "Cut"
        const val SELECT_ALL = "Select All"
        const val SHARE = "Share"
        const val SEARCH = "Search"
        const val TRANSLATE = "Translate"
        const val BROWSER = "Open In Browser"
    }
}
切り取り、コピー、貼り付け、全て選択
@Composable
fun CompactTextToolbar(
    value: TextFieldValue,
    onValueChange: (TextFieldValue) -> Unit,
    copyTitle: String = DynamicTextToolbarDefaults.Title.COPY,
    pasteTitle: String = DynamicTextToolbarDefaults.Title.PASTE,
    cutTitle: String = DynamicTextToolbarDefaults.Title.CUT,
    selectAllTitle: String = DynamicTextToolbarDefaults.Title.SELECT_ALL,
    content: @Composable () -> Unit
) {
    val clipboardManager = LocalClipboardManager.current
    val textToolbar = rememberDynamicTextToolbar()

    LaunchedEffect(value) {
        textToolbar.clearActions()

        if (value.isNotSelectedNone()) {
            textToolbar.addAction(
                title = cutTitle,
                action = {
                    val selectedText = value.getSelectedText()
                    clipboardManager.setText(selectedText)
                    onValueChange(value.removeSelectedText())
                }
            )

            textToolbar.addAction(
                title = copyTitle,
                action = {
                    val selectedText = value.getSelectedText()
                    clipboardManager.setText(selectedText)
                }
            )
        }

        textToolbar.addAction(
            title = pasteTitle,
            action = {
                val clipText = clipboardManager.getText() ?: return@addAction
                onValueChange(value.insertText(clipText))
            }
        )

        if (value.isNotSelectedAll()) {
            textToolbar.addAction(
                title = selectAllTitle,
                action = { onValueChange(value.selectAll()) }
            )
        }
    }

    CompositionLocalProvider(
        LocalTextToolbar provides textToolbar,
        content = content
    )
}
+ブラウザで開く、検索、翻訳、共有

インテント送信部分要改善

@Composable
fun AdvancedTextToolbar(
    value: TextFieldValue,
    onValueChange: (TextFieldValue) -> Unit,
    copyTitle: String = DynamicTextToolbarDefaults.Title.COPY,
    pasteTitle: String = DynamicTextToolbarDefaults.Title.PASTE,
    cutTitle: String = DynamicTextToolbarDefaults.Title.CUT,
    selectAllTitle: String = DynamicTextToolbarDefaults.Title.SELECT_ALL,
    shareTitle: String = DynamicTextToolbarDefaults.Title.SHARE,
    searchTitle: String = DynamicTextToolbarDefaults.Title.SEARCH,
    translateTitle: String = DynamicTextToolbarDefaults.Title.TRANSLATE,
    browserTitle: String = DynamicTextToolbarDefaults.Title.BROWSER,
    content: @Composable () -> Unit
) {
    val context = LocalContext.current
    val clipboardManager = LocalClipboardManager.current
    val textToolbar = rememberDynamicTextToolbar()

    LaunchedEffect(value) {
        textToolbar.clearActions()

        if (Patterns.WEB_URL.matcher(value.getSelectedText().text).matches()) {
            val intent = Intent().apply {
                action = Intent.ACTION_VIEW
                data = Uri.parse(value.getSelectedText().text)
            }
            textToolbar.addAction(
                title = browserTitle,
                action = { context.startActivity(intent) }
            )
        }

        if (value.isNotSelectedNone()) {
            textToolbar.addAction(
                title = cutTitle,
                action = {
                    val selectedText = value.getSelectedText()
                    clipboardManager.setText(selectedText)
                    onValueChange(value.removeSelectedText())
                }
            )

            textToolbar.addAction(
                title = copyTitle,
                action = {
                    val selectedText = value.getSelectedText()
                    clipboardManager.setText(selectedText)
                }
            )
        }

        textToolbar.addAction(
            title = pasteTitle,
            action = {
                val clipText = clipboardManager.getText() ?: return@addAction
                onValueChange(value.insertText(clipText))
            }
        )

        if (value.isNotSelectedAll()) {
            textToolbar.addAction(
                title = selectAllTitle,
                action = { onValueChange(value.selectAll()) }
            )
        }

        if (value.isNotSelectedNone()) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                val translateIntent = Intent().apply {
                    action = Intent.ACTION_TRANSLATE
                    putExtra(Intent.EXTRA_TEXT, value.getSelectedText().text)
                }
                textToolbar.addAction(
                    title = translateTitle,
                    action = { context.startActivity(translateIntent) }
                )
            }

            val searchIntent = Intent().apply {
                action = Intent.ACTION_WEB_SEARCH
                putExtra(SearchManager.QUERY, value.getSelectedText().text)
            }
            textToolbar.addAction(
                title = searchTitle,
                action = { context.startActivity(searchIntent) }
            )

            val shareIntent = Intent().apply {
                action = Intent.ACTION_SEND
                type = "text/plain"
                putExtra(Intent.EXTRA_TEXT, value.getSelectedText().text)
            }
            if (context.packageManager.queryIntentActivities(shareIntent, 0).isNotEmpty()) {
                textToolbar.addAction(
                    title = shareTitle,
                    action = { context.startActivity(shareIntent) }
                )
            }
        }
    }

    CompositionLocalProvider(
        LocalTextToolbar provides textToolbar,
        content = content
    )
}

参考

実装の概念的な部分が乗っていてわかりやすいです
https://qiita.com/toastkidjp/items/2e232fd276ef378bdb19

Discussion