Issue
I have implemented a column of buttons in jetpack compose. We realized it is possible to click multiple items at once (with multiple fingers for example), and we would like to disable this feature.
Is there an out of the box way to disable multiple simultaneous clicks on children composables by using a parent column modifier?
Here is an example of the current state of my ui, notice there are two selected items and two unselected items.
Here is some code of how it is implemented (stripped down)
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(nestedScrollParams.childScrollState),
) {
viewDataList.forEachIndexed { index, viewData ->
Row(modifier = modifier.fillMaxWidth()
.height(dimensionResource(id = 48.dp)
.background(colorResource(id = R.color.large_button_background))
.clickable { onClick(viewData) },
verticalAlignment = Alignment.CenterVertically
) {
//Internal composables, etc
}
}
Solution
Here are four solutions:
Click Debounce (ViewModel)r
For this, you need to use a viewmodel. The viewmodel handles the click event. You should pass in some id (or data) that identifies the item being clicked. In your example, you could pass an id that you assign to each item (such as a button id):
// IMPORTANT: Make sure to import kotlinx.coroutines.flow.collect
class MyViewModel : ViewModel() {
val debounceState = MutableStateFlow<String?>(null)
init {
viewModelScope.launch {
debounceState
.debounce(300)
.collect { buttonId ->
if (buttonId != null) {
when (buttonId) {
ButtonIds.Support -> displaySupport()
ButtonIds.About -> displayAbout()
ButtonIds.TermsAndService -> displayTermsAndService()
ButtonIds.Privacy -> displayPrivacy()
}
}
}
}
}
fun onItemClick(buttonId: String) {
debounceState.value = buttonId
}
}
object ButtonIds {
const val Support = "support"
const val About = "about"
const val TermsAndService = "termsAndService"
const val Privacy = "privacy"
}
The debouncer ignores any clicks that come in within 500 milliseconds of the last one received. I've tested this and it works. You'll never be able to click more than one item at a time. Although you can touch two at a time and both will be highlighted, only the first one you touch will generate the click handler.
Click Debouncer (Modifier)
This is another take on the click debouncer but is designed to be used as a Modifier. This is probably the one you will want to use the most. Most apps will make the use of scrolling lists that let you tap on a list item. If you quickly tap on an item multiple times, the code in the clickable
modifier will execute multiple times. This can be a nuisance. While users normally won't tap multiple times, I've seen even accidental double clicks trigger the clickable twice. Since you want to avoid this throughout your app on not just lists but buttons as well, you probably should use a custom modifier that lets you fix this issue without having to resort to the viewmodel approach shown above.
Create a custom modifier. I've named it onClick
:
fun Modifier.onClick(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onClick: () -> Unit
) = composed(
inspectorInfo = debugInspectorInfo {
name = "clickable"
properties["enabled"] = enabled
properties["onClickLabel"] = onClickLabel
properties["role"] = role
properties["onClick"] = onClick
}
) {
Modifier.clickable(
enabled = enabled,
onClickLabel = onClickLabel,
onClick = {
App.debounceClicks {
onClick.invoke()
}
},
role = role,
indication = LocalIndication.current,
interactionSource = remember { MutableInteractionSource() }
)
}
You'll notice that in the code above, I'm using App.debounceClicks
. This of course doesn't exist in your app. You need to create this function somewhere in your app where it is globally accessible. This could be a singleton object. In my code, I use a class that inherits from Application, as this is what gets instantiated when the app starts:
class App : Application() {
override fun onCreate() {
super.onCreate()
}
companion object {
private val debounceState = MutableStateFlow { }
init {
GlobalScope.launch(Dispatchers.Main) {
// IMPORTANT: Make sure to import kotlinx.coroutines.flow.collect
debounceState
.debounce(300)
.collect { onClick ->
onClick.invoke()
}
}
}
fun debounceClicks(onClick: () -> Unit) {
debounceState.value = onClick
}
}
}
Don't forget to include the name of your class in your AndroidManifest:
<application
android:name=".App"
Now instead of using clickable
, use onClick
instead:
Text("Do Something", modifier = Modifier.onClick { })
Globally disable multi-touch
In your main activity, override dispatchTouchEvent:
class MainActivity : AppCompatActivity() {
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
return ev?.getPointerCount() == 1 && super.dispatchTouchEvent(ev)
}
}
This disables multi-touch globally. If your app has a Google Maps, you will want to add some code to to dispatchTouchEvent to make sure it remains enabled when the screen showing the map is visible. Users will use two fingers to zoom on a map and that requires multi-touch enabled.
State Managed Click Handler
Use a single click event handler that stores the state of which item is clicked. When the first item calls the click, it sets the state to indicate that the click handler is "in-use". If a second item attempts to call the click handler and "in-use" is set to true, it just returns without performing the handler's code. This is essentially the equivalent of a synchronous handler but instead of blocking, any further calls just get ignored.
Answered By - Johann
Answer Checked By - David Marino (JavaFixing Volunteer)