android 如何禁用同时点击Jetpack组合列表/列/行中的多个项目(开箱即用防反跳?)

gywdnpxw  于 2023-01-28  发布在  Android
关注(0)|答案(7)|浏览(146)

我在jetpack compose中实现了一列按钮。我们意识到一次点击多个项目是可能的(例如用多个手指),我们想禁用这个功能。
有没有一种现成的方法可以通过使用父列修饰符来禁用对子可组合对象的多个同时单击?
下面是my 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
    }
}
cnjp1d6j

cnjp1d6j1#

检查此解决方案。它具有类似于splitMotionEvents=“false”标志的行为。将此扩展与列修饰符一起使用

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

zi8p0yeb2#

以下是四种解决方案:

单击去抖(视图模型)r

为此,你需要使用一个视图模型,视图模型处理点击事件,你应该传入一些标识被点击项的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修饰符中的代码将执行多次。这可能是一个麻烦。虽然用户通常不会点击多次,我曾经看到过,即使是不小心的双击也会触发两次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 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中包含您的类的名称:

<application
    android:name=".App"

现在使用onClick代替clickable

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

全局禁用多点触控

在主Activity中,覆盖dispatchTouchEvent:

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

这将全局禁用多点触控。如果你的应用有GoogleMap,你将需要向添加一些代码以dispatchTouchEvent,以确保在显示Map的屏幕可见时保持启用状态。用户将使用两个手指缩放Map,这需要启用多点触控。

状态管理单击处理程序

使用一个单击事件处理程序来存储单击项的状态。当第一个项调用单击时,它将状态设置为指示单击处理程序“正在使用”。如果第二个项尝试调用单击处理程序,并且“正在使用”设置为true,它将返回而不执行处理程序的代码。这基本上等同于同步处理程序,但不是阻塞。任何进一步的呼叫都将被忽略。

v6ylcynt

v6ylcynt3#

对于这个问题,我发现最简单的方法是保存列表中每个项目的单击状态,并在单击项目时将状态更新为"true"。
注意:只有在点击处理后列表将被重新组成的用例中,使用这种方法才能正常工作;例如当执行项目点击时导航到另一屏幕。
否则,如果您停留在同一个组合中并尝试单击另一个项目,则第二次单击将被忽略,依此类推。
例如:

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

3okqufwl4#

尝试关闭多点触控,或者在修饰符中添加单击,都不够灵活。我借鉴了@Johann的代码。我可以只在需要禁用时调用它,而不是在应用程序级别禁用。
下面是一个替代解决方案:

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

你就可以在任何地方使用它

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

或:

Text(modifier = Modifier.clickable { ClickHelper.getInstance().clickOnce {
         // Handle the click
     } } ) { }
m4pnthwp

m4pnthwp5#

这是我的解决方案。
它基于https://stackoverflow.com/a/69914674/7011814,因为我不使用GlobalScope(这里解释了原因),也不使用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)
}

第一个函数可用作代码的 Package 器,如下所示:

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

jk9hmnmh6#

只需在样式中添加两行。这将在整个应用程序中禁用多点触控:

<style name="AppTheme" parent="...">
    ...
    
    <item name="android:windowEnableSplitTouch">false</item>
    <item name="android:splitMotionEvents">false</item>
    
</style>
yuvru6vn

yuvru6vn7#

fun singleClick(onClick: () -> Unit): () -> Unit {
    var latest: Long = 0
    return {
        val now = System.currentTimeMillis()
        if (now - latest >= 300) {
            onClick()
            latest = now
        }
    }
}

然后您可以使用

Button(onClick = singleClick {
    // TODO
})

相关问题