diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/text/CustomSelectionAction.kt b/compose/snippets/src/main/java/com/example/compose/snippets/text/CustomSelectionAction.kt new file mode 100644 index 00000000..8904e11c --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/text/CustomSelectionAction.kt @@ -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() + } +} diff --git a/compose/snippets/src/main/res/values/strings.xml b/compose/snippets/src/main/res/values/strings.xml index faf8fd47..0e34afdc 100644 --- a/compose/snippets/src/main/res/values/strings.xml +++ b/compose/snippets/src/main/res/values/strings.xml @@ -14,7 +14,7 @@ ~ limitations under the License. ~ --> - + snippets Golden Retriever in fall leaves Shopping cart @@ -53,4 +53,5 @@ Shopping Profile This is just a placeholder. + Custom \ No newline at end of file