Skip to content

Commit f64a1d4

Browse files
Custom text action snippet
1 parent 68fef6e commit f64a1d4

File tree

2 files changed

+270
-0
lines changed

2 files changed

+270
-0
lines changed
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
package com.example.compose.snippets.text
2+
3+
import android.os.Build
4+
import android.view.ActionMode
5+
import android.view.Menu
6+
import android.view.MenuItem
7+
import android.view.View
8+
import androidx.annotation.RequiresApi
9+
import androidx.compose.foundation.layout.padding
10+
import androidx.compose.foundation.text.selection.SelectionContainer
11+
import androidx.compose.material3.Text
12+
import androidx.compose.runtime.Composable
13+
import androidx.compose.runtime.CompositionLocalProvider
14+
import androidx.compose.ui.Modifier
15+
import androidx.compose.ui.geometry.Rect
16+
import androidx.compose.ui.platform.LocalTextToolbar
17+
import androidx.compose.ui.platform.LocalView
18+
import androidx.compose.ui.platform.TextToolbar
19+
import androidx.compose.ui.platform.TextToolbarStatus
20+
import androidx.compose.ui.tooling.preview.Preview
21+
import androidx.compose.ui.unit.dp
22+
import com.example.compose.snippets.R
23+
import com.example.compose.snippets.touchinput.userinteractions.MyAppTheme
24+
25+
@Preview
26+
@Composable
27+
private fun TextSelectionCustomActionPreview() {
28+
MyAppTheme {
29+
TextSelectionCustomAction()
30+
}
31+
}
32+
33+
@Composable
34+
fun TextSelectionCustomAction(modifier: Modifier = Modifier) {
35+
val textToolbar = CustomTextToolbar(
36+
view = LocalView.current,
37+
onCustomActionRequested = {
38+
// Handle the custom action
39+
}
40+
)
41+
42+
CompositionLocalProvider(LocalTextToolbar provides textToolbar) {
43+
SelectionContainer {
44+
Text(
45+
text = "This text is selectable",
46+
modifier = modifier.padding(16.dp)
47+
)
48+
}
49+
}
50+
}
51+
52+
class CustomTextToolbar(
53+
private val view: View,
54+
onCustomActionRequested: (() -> Unit)
55+
) : TextToolbar {
56+
private var actionMode: ActionMode? = null
57+
private val textActionModeCallback: TextActionModeCallback =
58+
TextActionModeCallback(
59+
onActionModeDestroy = { actionMode = null },
60+
onCustomActionRequested = onCustomActionRequested
61+
)
62+
override var status: TextToolbarStatus = TextToolbarStatus.Hidden
63+
private set
64+
65+
override fun showMenu(
66+
rect: Rect,
67+
onCopyRequested: (() -> Unit)?,
68+
onPasteRequested: (() -> Unit)?,
69+
onCutRequested: (() -> Unit)?,
70+
onSelectAllRequested: (() -> Unit)?
71+
) {
72+
textActionModeCallback.rect = rect
73+
textActionModeCallback.onCopyRequested = onCopyRequested
74+
textActionModeCallback.onCutRequested = onCutRequested
75+
textActionModeCallback.onPasteRequested = onPasteRequested
76+
textActionModeCallback.onSelectAllRequested = onSelectAllRequested
77+
if (actionMode == null) {
78+
status = TextToolbarStatus.Shown
79+
actionMode = if (Build.VERSION.SDK_INT >= 23) {
80+
view.startActionMode(
81+
FloatingTextActionModeCallback(
82+
textActionModeCallback
83+
),
84+
ActionMode.TYPE_FLOATING
85+
)
86+
} else {
87+
view.startActionMode(
88+
PrimaryTextActionModeCallback(
89+
textActionModeCallback
90+
)
91+
)
92+
}
93+
} else {
94+
actionMode?.invalidate()
95+
}
96+
}
97+
98+
override fun hide() {
99+
status = TextToolbarStatus.Hidden
100+
actionMode?.finish()
101+
actionMode = null
102+
}
103+
}
104+
105+
internal class TextActionModeCallback(
106+
// The custom action callback
107+
val onCustomActionRequested: (() -> Unit),
108+
// Existing parameters, copied from the default AndroidTextToolbar implementation
109+
val onActionModeDestroy: (() -> Unit)? = null,
110+
var rect: Rect = Rect.Zero,
111+
var onCopyRequested: (() -> Unit)? = null,
112+
var onPasteRequested: (() -> Unit)? = null,
113+
var onCutRequested: (() -> Unit)? = null,
114+
var onSelectAllRequested: (() -> Unit)? = null,
115+
) {
116+
fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
117+
requireNotNull(menu) { "onCreateActionMode requires a non-null menu" }
118+
requireNotNull(mode) { "onCreateActionMode requires a non-null mode" }
119+
120+
// Always add the custom menu item
121+
addMenuItem(menu, MenuItemOption.Custom)
122+
123+
// Default menu items if available
124+
onCopyRequested?.let {
125+
addMenuItem(menu, MenuItemOption.Copy)
126+
}
127+
onPasteRequested?.let {
128+
addMenuItem(menu, MenuItemOption.Paste)
129+
}
130+
onCutRequested?.let {
131+
addMenuItem(menu, MenuItemOption.Cut)
132+
}
133+
onSelectAllRequested?.let {
134+
addMenuItem(menu, MenuItemOption.SelectAll)
135+
}
136+
return true
137+
}
138+
139+
// this method is called to populate new menu items when the actionMode was invalidated
140+
fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
141+
if (mode == null || menu == null) return false
142+
updateMenuItems(menu)
143+
// should return true so that new menu items are populated
144+
return true
145+
}
146+
147+
fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
148+
when (item!!.itemId) {
149+
// Call the custom action callback
150+
MenuItemOption.Custom.id -> onCustomActionRequested.invoke()
151+
// The default action callbacks
152+
MenuItemOption.Copy.id -> onCopyRequested?.invoke()
153+
MenuItemOption.Paste.id -> onPasteRequested?.invoke()
154+
MenuItemOption.Cut.id -> onCutRequested?.invoke()
155+
MenuItemOption.SelectAll.id -> onSelectAllRequested?.invoke()
156+
else -> return false
157+
}
158+
mode?.finish()
159+
return true
160+
}
161+
162+
fun onDestroyActionMode() {
163+
onActionModeDestroy?.invoke()
164+
}
165+
166+
internal fun updateMenuItems(menu: Menu) {
167+
addOrRemoveMenuItem(menu, MenuItemOption.Custom, onCustomActionRequested)
168+
addOrRemoveMenuItem(menu, MenuItemOption.Copy, onCopyRequested)
169+
addOrRemoveMenuItem(menu, MenuItemOption.Paste, onPasteRequested)
170+
addOrRemoveMenuItem(menu, MenuItemOption.Cut, onCutRequested)
171+
addOrRemoveMenuItem(menu, MenuItemOption.SelectAll, onSelectAllRequested)
172+
}
173+
174+
internal fun addMenuItem(menu: Menu, item: MenuItemOption) {
175+
menu.add(0, item.id, item.order, item.titleResource)
176+
.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
177+
}
178+
179+
private fun addOrRemoveMenuItem(
180+
menu: Menu,
181+
item: MenuItemOption,
182+
callback: (() -> Unit)?
183+
) {
184+
when {
185+
callback != null && menu.findItem(item.id) == null -> addMenuItem(menu, item)
186+
callback == null && menu.findItem(item.id) != null -> menu.removeItem(item.id)
187+
}
188+
}
189+
}
190+
191+
internal enum class MenuItemOption(val id: Int) {
192+
// The added custom item
193+
Custom(0),
194+
// The default items, copied from the default AndroidTextToolbar implementation
195+
Copy(1),
196+
Paste(2),
197+
Cut(3),
198+
SelectAll(4);
199+
200+
val titleResource: Int
201+
get() = when (this) {
202+
Custom -> R.string.custom
203+
Copy -> android.R.string.copy
204+
Paste -> android.R.string.paste
205+
Cut -> android.R.string.cut
206+
SelectAll -> android.R.string.selectAll
207+
}
208+
209+
/**
210+
* This item will be shown before all items that have order greater than this value.
211+
*/
212+
val order = id
213+
}
214+
215+
216+
@RequiresApi(23)
217+
internal class FloatingTextActionModeCallback(
218+
private val callback: TextActionModeCallback
219+
) : ActionMode.Callback2() {
220+
override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
221+
return callback.onActionItemClicked(mode, item)
222+
}
223+
224+
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
225+
return callback.onCreateActionMode(mode, menu)
226+
}
227+
228+
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
229+
return callback.onPrepareActionMode(mode, menu)
230+
}
231+
232+
override fun onDestroyActionMode(mode: ActionMode?) {
233+
callback.onDestroyActionMode()
234+
}
235+
236+
override fun onGetContentRect(
237+
mode: ActionMode?,
238+
view: View?,
239+
outRect: android.graphics.Rect?
240+
) {
241+
val rect = callback.rect
242+
outRect?.set(
243+
rect.left.toInt(),
244+
rect.top.toInt(),
245+
rect.right.toInt(),
246+
rect.bottom.toInt()
247+
)
248+
}
249+
}
250+
251+
internal class PrimaryTextActionModeCallback(
252+
private val callback: TextActionModeCallback
253+
) : ActionMode.Callback {
254+
override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
255+
return callback.onActionItemClicked(mode, item)
256+
}
257+
258+
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
259+
return callback.onCreateActionMode(mode, menu)
260+
}
261+
262+
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
263+
return callback.onPrepareActionMode(mode, menu)
264+
}
265+
266+
override fun onDestroyActionMode(mode: ActionMode?) {
267+
callback.onDestroyActionMode()
268+
}
269+
}

compose/snippets/src/main/res/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,5 @@
5353
<string name="shopping">Shopping</string>
5454
<string name="profile">Profile</string>
5555
<string name="detail_placeholder">This is just a placeholder.</string>
56+
<string name="custom">Custom</string>
5657
</resources>

0 commit comments

Comments
 (0)