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

10

30

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
    }
}
Withhold answered 9/11, 2021 at 16:21 Comment(7)
There is no multi-select in the code you've shown. Having a Column filled with items and each item having a click handler doesn't make it multi-select. In fact there is no select at all. A click handler doesn't "select" anything. It just handles a click event. So please post the code that is making your items multi-select. It appears that the click handler code you left out is changing the background color and keeping that state. But we have to see the code to know if that is true.Eristic
@Eristic you are right, it isn't persistent selection, it is the fact that you can click on multiple items simultaneously.Withhold
I don't understand what you want. You clearly stated that you want to "disable multiple simultaneous clicks" - But your code doesn't even support that. So what's the point?Eristic
@Eristic it does support it, the code is stripped down. Each cell can be selected by one finger. On the screenshot you can see two cells selected simultaneously. If I click with two fingers on two cells, both click listeners will be invoked. there is then two operations performed. I want to debounce all other operations once the first cell is selected.Withhold
You didn't indicate in your post that you are pressing two items at the same time. Multi-select isn't about pressing multiple items at the same time. Multi-select is when you have something like a list of checkboxes and you can select multiple items at the same time. Also, even if you do press two list items as you've shown, having both of them show as selected is normal. Only one of them is going to get clicked when you release your fingers. The two items are not going to remain selected as you've shown. So I don't understand what your issue is. Who even bothers to press two items?Eristic
Actually @Johann, both click listeners are in fact invoked. For example, we have two separate fragments for about and terms of service. If you click both items at the same time, one of the fragments will be shown, but when backing back out, the other fragment is visible. I do agree though that most users won't do it though, but the requirements are the requirements.Withhold
Now that you've clarified that you're dealing with "multi-touch" as opposed to "multi-select", I've posted a number of solutions.Eristic
E
7

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.

Eristic answered 10/11, 2021 at 14:3 Comment(2)
Looks like a really weird solution in opposite to android:splitMotionEvents="false" in xml.Superjacent
bad practice to use GlobalScope + this wont solve anything if you have a modularize app.Backler
S
12

Check this solution. It has similar behavior to splitMotionEvents="false" flag. Use this extension with your Column modifier

import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import kotlinx.coroutines.coroutineScope


fun Modifier.disableSplitMotionEvents() =
    pointerInput(Unit) {
        coroutineScope {    
            var currentId: Long = -1L    
            awaitPointerEventScope {    
                while (true) {
                awaitPointerEvent(PointerEventPass.Initial).changes.forEach { pointerInfo ->
                        when {
                            pointerInfo.pressed && currentId == -1L -> currentId = pointerInfo.id.value
                            pointerInfo.pressed.not() && currentId == pointerInfo.id.value -> currentId = -1
                            pointerInfo.id.value != currentId && currentId != -1L -> pointerInfo.consume()
                            else -> Unit
                        }
                    }
                }
            }
        }
    }
Sellers answered 30/6, 2022 at 13:12 Comment(1)
Works perfectly, elegant solution thanks!Freightage
S
9
fun singleClick(onClick: () -> Unit): () -> Unit {
    var latest: Long = 0
    return {
        val now = System.currentTimeMillis()
        if (now - latest >= 300) {
            onClick()
            latest = now
        }
    }
}

Then you can use

Button(onClick = singleClick {
    // TODO
})
Sidewalk answered 26/1, 2023 at 10:22 Comment(0)
E
7

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.

Eristic answered 10/11, 2021 at 14:3 Comment(2)
Looks like a really weird solution in opposite to android:splitMotionEvents="false" in xml.Superjacent
bad practice to use GlobalScope + this wont solve anything if you have a modularize app.Backler
F
3

The most simple approach that I found for this issue is to save the click state for each Item on the list, and update the state to 'true' if an item is clicked.

NOTE: Using this approach works properly only in a use-case where the list will be re-composed after the click handling; for example navigating to another Screen when the item click is performed.

Otherwise if you stay in the same Composable and try to click another item, the second click will be ignored and so on.

for example:

@Composable
fun MyList() {

    // Save the click state in a MutableState
    val isClicked = remember {
        mutableStateOf(false)
    }

    LazyColumn {
        items(10) {
            ListItem(index = "$it", state = isClicked) {
               // Handle the click
            }
        }
    }
}

ListItem Composable:

@Composable
fun ListItem(
    index: String,
    state: MutableState<Boolean>,
    onClick: () -> Unit
) {
    Text(
        text = "Item $index",
        modifier = Modifier
            .clickable {
                // If the state is true, escape the function
                if (state.value)
                    return@clickable
                
                // else, call onClick block
                onClick()
                state.value = true
            }
    )
}
Farrish answered 14/12, 2021 at 11:21 Comment(0)
C
3

Trying to turn off multi-touch, or adding single click to the modifier, is not flexible enough. I borrowed the idea from @Johann‘s code. Instead of disabling at the app level, I can call it only when I need to disable it.

Here is an Alternative solution:

class ClickHelper private constructor() {
    private val now: Long
        get() = System.currentTimeMillis()
    private var lastEventTimeMs: Long = 0
    fun clickOnce(event: () -> Unit) {
        if (now - lastEventTimeMs >= 300L) {
            event.invoke()
        }
        lastEventTimeMs = now
    }
    companion object {
        @Volatile
        private var instance: ClickHelper? = null
        fun getInstance() =
            instance ?: synchronized(this) {
                instance ?: ClickHelper().also { instance = it }
            }
    }
}

then you can use it anywhere you want:

Button(onClick = { ClickHelper.getInstance().clickOnce {
           // Handle the click
       } } ) { }

or:

Text(modifier = Modifier.clickable { ClickHelper.getInstance().clickOnce {
         // Handle the click
     } } ) { }
Chickenlivered answered 29/6, 2022 at 2:31 Comment(0)
O
3

Use this function your onCLick lambda works 100% of the time:

Button(
  onClick = singleClick { onClick()}
)


fun singleClick(onClick: () -> Unit): () -> Unit {
    var latest: Long = 0
    return {
        val now = System.currentTimeMillis()
        if (now - latest >= 1000) {
            onClick()
            latest = now
        }
    }
Oriole answered 25/1 at 10:40 Comment(0)
M
0

Here is my solution.

It's based on https://mcmap.net/q/476244/-how-to-disable-simultaneous-clicks-on-multiple-items-in-jetpack-compose-list-column-row-out-of-the-box-debounce by I don't use GlobalScope (here is an explanation why) and I don't use MutableStateFlow as well (because its combination with GlobalScope may cause a potential memory leak).

Here is a head stone of the solution:

@OptIn(FlowPreview::class)
@Composable
fun <T>multipleEventsCutter(
    content: @Composable (MultipleEventsCutterManager) -> T
) : T {
    val debounceState = remember {
        MutableSharedFlow<() -> Unit>(
            replay = 0,
            extraBufferCapacity = 1,
            onBufferOverflow = BufferOverflow.DROP_OLDEST
        )
    }

    val result = content(
        object : MultipleEventsCutterManager {
            override fun processEvent(event: () -> Unit) {
                debounceState.tryEmit(event)
            }
        }
    )

    LaunchedEffect(true) {
        debounceState
            .debounce(CLICK_COLLAPSING_INTERVAL)
            .collect { onClick ->
                onClick.invoke()
            }
    }

    return result
}

@OptIn(FlowPreview::class)
@Composable
fun MultipleEventsCutter(
    content: @Composable (MultipleEventsCutterManager) -> Unit
) {
    multipleEventsCutter(content)
}

The first function can be used as a wrapper around your code like this:

    MultipleEventsCutter { multipleEventsCutterManager ->
        Button(
            onClick = { multipleClicksCutter.processEvent(onClick) },
            ...
        ) {
           ...
        }     
    }

And you can use the second one to create your own modifier, like next one:

fun Modifier.clickableSingle(
    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
    }
) {
    multipleEventsCutter { manager ->
        Modifier.clickable(
            enabled = enabled,
            onClickLabel = onClickLabel,
            onClick = { manager.processEvent { onClick() } },
            role = role,
            indication = LocalIndication.current,
            interactionSource = remember { MutableInteractionSource() }
        )
    }
}
Maighdlin answered 7/2, 2022 at 19:35 Comment(0)
R
0

Just add two lines in your styles. This will disable multitouch in whole application:

<style name="AppTheme" parent="...">
    ...
    
    <item name="android:windowEnableSplitTouch">false</item>
    <item name="android:splitMotionEvents">false</item>
    
</style>
Raleigh answered 5/6, 2022 at 15:53 Comment(0)
R
0

Based on some other answers here, here's a single composable that uses a SharedFlow, the flow transform operator and the System time to limit the window in which multiple taps can be made on a clickable element. This works for preventing multi-taps on a single composable item as opposed to multiple composable items like the question asked. But some others may find this useful.

/**
 * A simple copy of the [clickable] Modifier with the ability to ignore
 * subsequent click events if made within the specified [clickDebounceWindow].
 * This is useful for preventing rapid double-tap click actions.
 *
 * @param clickDebounceWindow The time window in millis in which to ignore subsequent clicks.
 *
 * @see [clickable]
 */
fun Modifier.debounceClickable(
    enabled: Boolean = true,
    onClickLabel: String? = null,
    role: Role? = null,
    clickDebounceWindow: Long = 1_000L,
    onClick: () -> Unit,
) = composed(
    inspectorInfo = debugInspectorInfo {
        name = "clickable"
        properties["enabled"] = enabled
        properties["onClickLabel"] = onClickLabel
        properties["role"] = role
        properties["onClick"] = onClick
    }
) {
    val debounceClickState = remember {
        MutableSharedFlow<() -> Unit>(
            extraBufferCapacity = 1,
            onBufferOverflow = BufferOverflow.DROP_OLDEST,
        )
    }

    var lastEventTime by remember { mutableStateOf(0L) }

    LaunchedEffect(Unit) {
        debounceClickState.transform {
            // Only emit click events if the clickDebounce
            // millis have passed since the last click event
            val now = System.currentTimeMillis()
            if (now - lastEventTime > clickDebounceWindow) {
                emit(it)
                lastEventTime = now
            }
        }.collect { clickEvent ->
            clickEvent.invoke()
        }
    }

    Modifier.clickable(
        enabled = enabled,
        onClickLabel = onClickLabel,
        onClick = { debounceClickState.tryEmit(onClick) },
        role = role,
        indication = LocalIndication.current,
        interactionSource = remember { MutableInteractionSource() }
    )
}
Raleighraley answered 2/5, 2023 at 15:45 Comment(0)
A
0

When it comes to a button, my solution is quite simple. I have created ClickableButton in which we can track the state of the clicked button like this:

@Composable
fun ClickableButton() {
    var buttonEnabled by remember { mutableStateOf(true) }

    Column(
        modifier = Modifier.padding(16.dp)
    ) {
        Button(
            onClick = {
                if (buttonEnabled) {
                    buttonEnabled = false //👈

                    LaunchedEffect(Unit) {
                        delay(2000)
                        buttonEnabled = true //👈
                    }
                }
            },
            enabled = buttonEnabled  //👈
        ) {
            Text("Click Me")
        }
    }
}

So I disable the button temporarily to prevent multiple clicks. Right after that, we can perform an async operation. In this example, I have just simulated a delay. After 2 seconds, when the operation completes, the button will be enabled again.

Aesculapian answered 15/2 at 9:45 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.