I’m using Jetpack compose to create UI in my android app. I have this little problem : The android’s cut/Copy/Paste popup looks misplaced when we scroll on the Lazy column. The popup sticks there but the other elements scrolls up and down. I want it to behave like we have in Gmail app or dismissed when user scroll.
here I have added a todo in my code where I can handle this popup
@Composable
private fun ResultUI(modifier: Modifier,
results: List<DictionaryResult>,
onDefinitionTap: (String) -> Unit,
onExampleTap: (String) -> Unit) {
val lazyListState = rememberLazyListState(
// TODO detect scroll change and dismiss or move cutCopyPaste popup
)
LazyColumn(modifier = modifier.padding(top = 8.dp), state = lazyListState){
item {
TextField(
value = "",
onValueChange = {})
}
items(results) {
for(m in it.meanings ?: emptyList()) {
MeaningUi(meaning = m, onDefinitionTap, onExampleTap)
}
}
}
}
I need to know how to handle this Android’s cutCopyPaste popup programmatically.
To explain my question in simple way I have added two Gifs
What I want
What I have
You can accomplish this with a custom TextToolbar
. AndroidTextToolbar is the default one. You can copy it and modify based on your requirements.
I copy-pasted default one, since changing it might take some time. You can modify it based on your requirements. Second one uses copy-paste which can be modified.
@Preview
@Composable
fun Test() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
val view = LocalView.current
val textToolbar = remember {
AndroidTextToolbar(view)
}
var text1 by remember {
mutableStateOf("")
}
var text2 by remember {
mutableStateOf("")
}
TextField(value = text1, onValueChange = { text1 = it })
CompositionLocalProvider(
LocalTextToolbar provides textToolbar
) {
TextField(value = text2, onValueChange = { text2 = it })
}
}
}
internal class AndroidTextToolbar(private val view: View) : TextToolbar {
private var actionMode: ActionMode? = null
private val textActionModeCallback: TextActionModeCallback = TextActionModeCallback(
onActionModeDestroy = {
actionMode = null
}
)
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) {
TextToolbarHelperMethods.startActionMode(
view,
FloatingTextActionModeCallback(textActionModeCallback),
ActionMode.TYPE_FLOATING
)
} else {
view.startActionMode(
PrimaryTextActionModeCallback(textActionModeCallback)
)
}
} else {
actionMode?.invalidate()
}
}
override fun hide() {
status = TextToolbarStatus.Hidden
actionMode?.finish()
actionMode = null
}
}
/**
* This class is here to ensure that the classes that use this API will get verified and can be
* AOT compiled. It is expected that this class will soft-fail verification, but the classes
* which use this method will pass.
*/
@RequiresApi(23)
internal object TextToolbarHelperMethods {
@RequiresApi(23)
@DoNotInline
fun startActionMode(
view: View,
actionModeCallback: ActionMode.Callback,
type: Int
): ActionMode? {
return view.startActionMode(
actionModeCallback,
type
)
}
@RequiresApi(23)
@DoNotInline
fun invalidateContentRect(actionMode: ActionMode) {
actionMode.invalidateContentRect()
}
}
internal class TextActionModeCallback(
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" }
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) {
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()
}
@VisibleForTesting
internal fun updateMenuItems(menu: Menu) {
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) {
Copy(0),
Paste(1),
Cut(2),
SelectAll(3);
val titleResource: Int
get() = when (this) {
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()
}
}