👀
【雑記】TextToolbarを他のComposeにそれっぽく寄せた書き方で動かしてみた
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
)
}
参考
実装の概念的な部分が乗っていてわかりやすいです
Discussion