如何禁用同时单击 Jetpack Compose 列表/列/行中的多个项目(开箱即用的去抖?)

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

我在 Jetpack Compose 中实现了一列按钮。我们意识到可以一次单击多个项目(例如用多个手指),我们想禁用此功能。

是否有开箱即用的方法来使用父列修饰符禁用对子可组合项的多次同时点击?

这是我的 ui 当前状态的示例,请注意有两个已选项目和两个未选项目。

下面是一些它是如何实现的代码(精简)

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
    }
}

这里有四个解决方案:

点击去抖动 (ViewModel)r

为此,您需要使用视图模型。 viewmodel 处理点击事件。您应该传入一些标识被单击项目的 ID(或数据)。在您的示例中,您可以传递分配给每个项目的 ID(例如按钮 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"
}

去抖器会忽略在最后一次收到点击后 500 毫秒内进入的任何点击。我已经测试过这个并且它有效。您永远无法一次单击多个项目。虽然您可以一次触摸两个并且都将突出显示,但只有您触摸的第一个会生成点击处理程序。

点击去抖器(修改器)

这是点击去抖动器的另一种形式,但旨在用作修改器。这可能是您最想使用的一个。大多数应用程序都会使用滚动列表,让您点击列表项。如果多次快速点击某个项目,clickable 修饰符中的代码将执行多次。这可能很麻烦。虽然用户通常不会点击多次,但我已经看到即使是意外的双击也会触发可点击两次。由于您希望在整个应用程序中不仅在列表上而且在按钮上都避免这种情况,因此您可能应该使用自定义修饰符来解决此问题,而不必求助于上面显示的视图模型方法。

创建自定义修改器。我将其命名为 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() }
    )
}

您会注意到,在上面的代码中,我使用了 App.debounceClicks。这当然不存在于您的应用程序中。您需要在您的应用程序中可全局访问的某个位置创建此功能。这可能是一个单例对象。在我的代码中,我使用了从 Application 继承的 class,因为这是在应用程序启动时实例化的内容:

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
        }
    }
}

不要忘记在 AndroidManifest 中包含 class 的名称:

<application
    android:name=".App"

现在不再使用 clickable,而是使用 onClick

Text("Do Something", modifier = Modifier.onClick { })

全局禁用多点触控

在您的主 activity 中,覆盖 dispatchTouchEvent:

class MainActivity : AppCompatActivity() {
    override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
        return ev?.getPointerCount() == 1 && super.dispatchTouchEvent(ev)
    }
}

这将全局禁用多点触控。如果您的应用程序有 Google 地图,您需要向 dispatchTouchEvent 添加一些代码,以确保它在显示地图的屏幕可见时保持启用状态。用户将使用两根手指放大地图,这需要启用多点触控。

状态管理点击处理程序

使用存储单击项目状态的单击事件处理程序。当第一个项目调用点击时,它设置状态以指示点击处理程序“正在使用”。如果第二个项目试图调用点击处理程序并且“in-use”设置为 true,它只是 returns 而不会执行处理程序的代码。这本质上等同于同步处理程序,但不会阻塞,任何进一步的调用都会被忽略。

对于这个问题,我发现的最简单的方法是保存列表中每个项目的点击状态,并在点击项目时将状态更新为 'true'。

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.

例如:

@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
            }
        }
    }
}

可组合列表项:

@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
            }
    )
}

这是我的解决方案。

它基于 我不使用 GlobalScope (here is an explanation why),也不使用 MutableStateFlow(因为它与 GlobalScope 的结合可能会导致潜在的内存泄漏)。

这是解决方案的头石:

@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)
}

第一个函数可以像这样用作代码的包装器:

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

你可以使用第二个来创建你自己的修饰符,就像下一个一样:

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() }
        )
    }
}