Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom text action snippet #374

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.example.compose.snippets.text

import android.os.Build
import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.platform.LocalTextToolbar
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.platform.TextToolbarStatus
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.compose.snippets.R
import com.example.compose.snippets.touchinput.userinteractions.MyAppTheme

@Preview
@Composable
private fun TextSelectionCustomActionPreview() {
MyAppTheme {
TextSelectionCustomAction()
}
}

@Composable
fun TextSelectionCustomAction(modifier: Modifier = Modifier) {
val textToolbar = CustomTextToolbar(
view = LocalView.current,
onCustomActionRequested = {
// Handle the custom action
}
)

CompositionLocalProvider(LocalTextToolbar provides textToolbar) {
SelectionContainer {
Text(
text = "This text is selectable",
modifier = modifier.padding(16.dp)
)
}
}
}

class CustomTextToolbar(
private val view: View,
onCustomActionRequested: (() -> Unit)
) : TextToolbar {
private var actionMode: ActionMode? = null
private val textActionModeCallback: TextActionModeCallback =
TextActionModeCallback(
onActionModeDestroy = { actionMode = null },
onCustomActionRequested = onCustomActionRequested
)
override var status: TextToolbarStatus = TextToolbarStatus.Hidden
private set

override fun showMenu(
rect: Rect,
onCopyRequested: (() -> Unit)?,
onPasteRequested: (() -> Unit)?,
onCutRequested: (() -> Unit)?,
onSelectAllRequested: (() -> Unit)?
) {
textActionModeCallback.rect = rect
textActionModeCallback.onCopyRequested = onCopyRequested
textActionModeCallback.onCutRequested = onCutRequested
textActionModeCallback.onPasteRequested = onPasteRequested
textActionModeCallback.onSelectAllRequested = onSelectAllRequested
if (actionMode == null) {
status = TextToolbarStatus.Shown
actionMode = if (Build.VERSION.SDK_INT >= 23) {
view.startActionMode(
FloatingTextActionModeCallback(
textActionModeCallback
),
ActionMode.TYPE_FLOATING
)
} else {
view.startActionMode(
PrimaryTextActionModeCallback(
textActionModeCallback
)
)
}
} else {
actionMode?.invalidate()
}
}

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

internal class TextActionModeCallback(
// The custom action callback
val onCustomActionRequested: (() -> Unit),
// Existing parameters, copied from the default AndroidTextToolbar implementation
val onActionModeDestroy: (() -> Unit)? = null,
var rect: Rect = Rect.Zero,
var onCopyRequested: (() -> Unit)? = null,
var onPasteRequested: (() -> Unit)? = null,
var onCutRequested: (() -> Unit)? = null,
var onSelectAllRequested: (() -> Unit)? = null,
) {
fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
requireNotNull(menu) { "onCreateActionMode requires a non-null menu" }
requireNotNull(mode) { "onCreateActionMode requires a non-null mode" }

// Always add the custom menu item
addMenuItem(menu, MenuItemOption.Custom)

// Default menu items if available
onCopyRequested?.let {
addMenuItem(menu, MenuItemOption.Copy)
}
onPasteRequested?.let {
addMenuItem(menu, MenuItemOption.Paste)
}
onCutRequested?.let {
addMenuItem(menu, MenuItemOption.Cut)
}
onSelectAllRequested?.let {
addMenuItem(menu, MenuItemOption.SelectAll)
}
return true
}

// this method is called to populate new menu items when the actionMode was invalidated
fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
if (mode == null || menu == null) return false
updateMenuItems(menu)
// should return true so that new menu items are populated
return true
}

fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
when (item!!.itemId) {
// Call the custom action callback
MenuItemOption.Custom.id -> onCustomActionRequested.invoke()
// The default action callbacks
MenuItemOption.Copy.id -> onCopyRequested?.invoke()
MenuItemOption.Paste.id -> onPasteRequested?.invoke()
MenuItemOption.Cut.id -> onCutRequested?.invoke()
MenuItemOption.SelectAll.id -> onSelectAllRequested?.invoke()
else -> return false
}
mode?.finish()
return true
}

fun onDestroyActionMode() {
onActionModeDestroy?.invoke()
}

internal fun updateMenuItems(menu: Menu) {
addOrRemoveMenuItem(menu, MenuItemOption.Custom, onCustomActionRequested)
addOrRemoveMenuItem(menu, MenuItemOption.Copy, onCopyRequested)
addOrRemoveMenuItem(menu, MenuItemOption.Paste, onPasteRequested)
addOrRemoveMenuItem(menu, MenuItemOption.Cut, onCutRequested)
addOrRemoveMenuItem(menu, MenuItemOption.SelectAll, onSelectAllRequested)
}

internal fun addMenuItem(menu: Menu, item: MenuItemOption) {
menu.add(0, item.id, item.order, item.titleResource)
.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
}

private fun addOrRemoveMenuItem(
menu: Menu,
item: MenuItemOption,
callback: (() -> Unit)?
) {
when {
callback != null && menu.findItem(item.id) == null -> addMenuItem(menu, item)
callback == null && menu.findItem(item.id) != null -> menu.removeItem(item.id)
}
}
}

internal enum class MenuItemOption(val id: Int) {
// The added custom item
Custom(0),
// The default items, copied from the default AndroidTextToolbar implementation
Copy(1),
Paste(2),
Cut(3),
SelectAll(4);

val titleResource: Int
get() = when (this) {
Custom -> R.string.custom
Copy -> android.R.string.copy
Paste -> android.R.string.paste
Cut -> android.R.string.cut
SelectAll -> android.R.string.selectAll
}

/**
* This item will be shown before all items that have order greater than this value.
*/
val order = id
}

@RequiresApi(23)
internal class FloatingTextActionModeCallback(
private val callback: TextActionModeCallback
) : ActionMode.Callback2() {
override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
return callback.onActionItemClicked(mode, item)
}

override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
return callback.onCreateActionMode(mode, menu)
}

override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
return callback.onPrepareActionMode(mode, menu)
}

override fun onDestroyActionMode(mode: ActionMode?) {
callback.onDestroyActionMode()
}

override fun onGetContentRect(
mode: ActionMode?,
view: View?,
outRect: android.graphics.Rect?
) {
val rect = callback.rect
outRect?.set(
rect.left.toInt(),
rect.top.toInt(),
rect.right.toInt(),
rect.bottom.toInt()
)
}
}

internal class PrimaryTextActionModeCallback(
private val callback: TextActionModeCallback
) : ActionMode.Callback {
override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
return callback.onActionItemClicked(mode, item)
}

override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
return callback.onCreateActionMode(mode, menu)
}

override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
return callback.onPrepareActionMode(mode, menu)
}

override fun onDestroyActionMode(mode: ActionMode?) {
callback.onDestroyActionMode()
}
}
3 changes: 2 additions & 1 deletion compose/snippets/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
~ limitations under the License.
~
-->
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name">snippets</string>
<string name="dog_content_description">Golden Retriever in fall leaves</string>
<string name="shopping_cart_content_desc">Shopping cart</string>
Expand Down Expand Up @@ -53,4 +53,5 @@
<string name="shopping">Shopping</string>
<string name="profile">Profile</string>
<string name="detail_placeholder">This is just a placeholder.</string>
<string name="custom" tools:ignore="MissingTranslation">Custom</string>
</resources>