androidkotlinandroid-jetpack-composemulti-selectmulti-touch

How to disable simultaneous clicks on multiple items in Jetpack Compose List / Column / Row (out of the box debounce?)


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's 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.

enter image description here

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.