概要
Jetpack Compose を使って UI を実装すると、テキスト選択時のメニューが基本的なものしか使えないので追加したくなります。
EditText のようにテキストを選択して何かしらの操作をするメニューを実装したい場合、
Android 版の Jetpack Compose では Desktop 版のようなインターフェイスは用意されていないので、別の方法で実装する必要があります。
調べたところ、TextToolbar を継承したクラスに ComposableView を渡し、従来の View の startActionMode を呼び出すことで実現できそうでした。
表示するメニューの項目は XML で定義します。
なお、これをやるとデフォルトの cut / copy / paste / select all のメニューが出なくなるので、別途自分で呼び出すよう実装する必要があります。
(メニューIDの追加と呼び出しだけで可、実際の動きは引数のコールバックを使うだけでいい)
実装
以下の3ステップで実装可能です。
- メニューリソースの定義
- TextToolbar を継承したカスタムの TextToolbar を実装
- CustomTextToolbar を LocalTextToolbar に provide
1. メニューリソースの定義
従来の ActionMode で使っていたものがあれば、それをそのまま使えます。res/menu 以下に置いてください。
<?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 は重複表示させないための制御に使います。
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 を以下のように設定してください。
CompositionLocalProvider(
LocalTextToolbar provides CustomTextToolbar(LocalView.current) // <- ここ
) {
BasicTextField(
TextField に限らず、様々な Composable で導入可能です。
注意
EditText は特別に挿入時と選択時で別々の ActionModeCallback を設定することができました。
- customInsertionActionModeCallback
- customSelectionActionModeCallback
EditText の代替として TextField 等を使っていて、この方法でカスタムのメニューを追加したい時、
上記のようなコールバックの使い分けはできないため、TextFieldValue の状態を見て Inflate するメニューを切り替える、というやり方で実現できます。
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
- android - How to disable copy/paste/cut in a TextField Jetpack Compose? - Stack Overflow
TextField 以外の Composable で選択されたテキストを使う(あまりお勧めできない方法)
Compose の TextField の場合は State が選択されたテキストを保持しているので特に問題とはならないのですが、
SelectionContainer の中にある Text 等の文字列を取得するのは 1.6.0 の時点だと非常に困難です。選択されたテキストに関する API がことごとく internal で隠されています。
SelectionContainer の実装を見に行くとそれらしきコールバックがあるのですが、
// 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
)
}
一般の開発者は使うことができません。
// 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 コールバックを使うやり方を紹介します。
- ClipboardManager から現在の Primary Clip のテキストを取得して保持
- TextToolbar.showMenu の onCopyRequested を実行して選択されたテキストを取得
- 1のテキストを ClipboardManager の Primary Clip に再セット
コードは以下の通りです。
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 では以下の通りやると独自のメニューを実装できます。
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 です。
CompositionLocalProvider(
LocalTextContextMenu provides TextContextMenuFactory().invoke()
) {
SomeComponent()
}