返回

Jetpack Compose: pointerInput 实现按钮长按连续操作

Android

Jetpack Compose 实现按钮点击与长按连续操作:单击+1,长按每0.5秒+10

写 Compose UI 时,遇到个挺常见的交互需求:做一个加减按钮,点一下,数字加/减 1;按住不放,就希望它每隔一小段时间(比如 0.5 秒)自动加/减 10,直到松手为止。

你可能像我最初一样,想用 combinedClickable 搞定,它有 onClickonLongClick 两个参数,看起来很对路。但试了下就会发现,onLongClick 只会在长按动作被识别的那一刻触发一次,并不会在你按住期间持续触发。这就不符合咱“按住持续加减”的需求了。

原始代码大概是这样(用了两个 Box 分别做加减):

// 减法按钮 (简化示意)
Box(
    modifier = Modifier
        // ... 其他修饰符
        .combinedClickable(
            onClick = { /* 处理单击减 1 */ },
            onLongClick = { /* 处理长按减 10 (只会触发一次) */ }
        )
) {}

// 加法按钮 (简化示意)
Box(
    modifier = Modifier
        // ... 其他修饰符
        .combinedClickable(
            onClick = { /* 处理单击加 1 */ },
            onLongClick = { /* 处理长按加 10 (只会触发一次) */ }
        )
) {}

这显然达不到长按连续触发的效果。

为啥 combinedClickable 不够用?

简单说,combinedClickable 的设计目标就是区分“单击”和“长按”这两种独立的、一次性的事件。它内部判断:手指按下,如果很快抬起,就是 onClick;如果按住超过一定时间(通常是几百毫秒),就触发 onLongClick,然后就完事了。它没有内置“按住持续触发”的机制。

要实现持续触发,咱们得自己动手,监听更底层的触摸事件,并且结合协程来管理重复执行的逻辑。

动手解决:pointerInput 和协程联手

思路是这样的:

  1. pointerInput 修饰符来捕获原始的触摸按下 (press) 和抬起/取消 (release/cancel) 事件。
  2. 在检测到按下时,启动一个协程。
  3. 这个协程先等待一个小的延迟(比如 500 毫秒,也就是我们定义的“长按”阈值)。
  4. 如果用户在这段时间内没有松手,协程就开始执行重复任务:执行一次+10操作,然后等待 0.5 秒,再执行一次+10,如此循环。
  5. 如果用户在任何时候松手了,pointerInput 能检测到,咱们就立即取消那个正在重复执行的协程。
  6. 如果用户在长按阈值到达 之前 就松手了,那就判定为单击,执行+1操作。

实现步骤

下面我们把思路拆解成代码步骤,并封装成一个可复用的 Composable 函数。

1. 监听原始触摸事件 (pointerInput)

我们需要 pointerInput 修饰符,它允许我们访问底层的指针事件。里面通常会用 detectTapGestures 或更底层的 awaitPointerEventScope。对于这个需求,detectTapGestures 提供的 onPress 回调非常合适,它能明确告诉我们按下的时刻,并允许我们等待释放事件。

import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.input.pointer.pointerInput
// ... 其他 imports

// 在你的 Button 或 Box 上添加 pointerInput
Modifier.pointerInput(Unit) { // key=Unit 表示这个 block 不会因为外部状态变化而重启
    detectTapGestures(
        onPress = { offset -> // 按下时触发,offset 是按下的位置
            // 这里是关键逻辑
            // 尝试等待释放事件
            val released = tryAwaitRelease() // 这是一个 suspend 函数

            if (released) {
                // 如果在协程逻辑启动前就释放了,说明是单击
                // 执行单击操作 (+1 / -1)
            } else {
                // 如果 tryAwaitRelease 返回 false,说明不是正常释放
                // (可能是手势被取消等,这里简单处理为也停止)
                // 停止长按逻辑(如果正在运行)
            }
        },
        onTap = { /* 这里留空或处理单击,但 onPress 提供了更灵活的控制 */ }
    )
}

2. 管理按压状态和协程

我们需要知道按钮当前是否处于“被按住”的状态,并且需要一个协程作用域来启动和取消我们的重复任务。

import androidx.compose.runtime.*
import kotlinx.coroutines.*

// 在你的 Composable 函数内部
var currentValue by remember { mutableStateOf(0) } // 示例状态
val scope = rememberCoroutineScope() // 获取协程作用域
val interactionSource = remember { MutableInteractionSource() } // 用于视觉反馈
val isPressed by interactionSource.collectIsPressedAsState() // 跟踪按压状态(用于视觉)

// 用于控制长按重复任务的 Job
var longPressJob by remember { mutableStateOf<Job?>(null) }

3. 结合 onPress、协程和状态管理

现在,把它们组合起来。我们需要在 onPress 内部精细控制:

@Composable
fun HoldableActionButton(
    modifier: Modifier = Modifier,
    initialValue: Int,
    step: Int = 1,           // 单击步长
    longPressStep: Int = 10, // 长按步长
    longPressDelayMillis: Long = 500, // 长按触发延迟
    longPressIntervalMillis: Long = 500, // 长按重复间隔
    minValue: Int = Int.MIN_VALUE,
    maxValue: Int = Int.MAX_VALUE,
    onValueChange: (Int) -> Unit,
    content: @Composable (value: Int, isPressed: Boolean) -> Unit // 允许自定义按钮内容
) {
    val scope = rememberCoroutineScope()
    var longPressJob by remember { mutableStateOf<Job?>(null) }
    val interactionSource = remember { MutableInteractionSource() }
    val isPressed by interactionSource.collectIsPressedAsState()

    // 核心逻辑:使用 Box 包裹并应用 pointerInput
    Box(
        modifier = modifier
            .clickable( // 使用 clickable 提供基础的交互状态和语义
                interactionSource = interactionSource,
                indication = LocalIndication.current, // 使用默认或自定义的点击涟漪效果
                onClick = {} // onClick 本身可以留空,主要逻辑在 pointerInput
            )
            .pointerInput(initialValue, step, longPressStep, longPressDelayMillis, longPressIntervalMillis, minValue, maxValue, onValueChange) {
                detectTapGestures(
                    onPress = { offset ->
                        longPressJob?.cancel() // 先取消之前的任务(如果有)
                        val pressStartTime = System.currentTimeMillis()
                        var GogoTap = true//点击单击
                        try {
                             val pressJob = scope.launch {
                                delay(longPressDelayMillis) // 等待长按阈值
                                 GogoTap = false//执行到这里就是长按了。那么单击设置为false,以免跟下面up冲突
                                // 触发长按逻辑
                                scope.launch { // 启动一个新的内部协程来处理重复任务
                                    while (isActive) { // isActive 会在协程被取消时变为 false
                                        val newValue = (initialValue + longPressStep).coerceIn(minValue, maxValue)
                                        if (newValue != initialValue) {
                                            onValueChange(newValue)
                                        }else{
                                            //数值等于限制值后,自动停掉。
                                            longPressJob?.cancel()
                                        }
                                        delay(longPressIntervalMillis) // 等待重复间隔
                                    }
                                }.also { longPressJob = it } // 保存这个重复任务的 Job
                            }

                             pressJob.join()// 等待这个启动协程结束(即等待长按阈值过去或被取消)


                            // 更新按压状态 (这是 clickable 自动处理的,但这里显式模拟)
                            val press = PressInteraction.Press(offset)
                            interactionSource.emit(press)

                            // 等待释放
                             val released = awaitRelease()


                            //---Up 后执行,不管是长按up还是单击up都会执行
                             pressJob.cancel() // 取消等待长按阈值的协程(防止按下后不满足长按时间触发长按逻辑)
                             longPressJob?.cancel()//停止循环任务

                             if (released && GogoTap) {
                                // ---执行单击事件: 如果正常释放,并且没有执行过长按任务,认为是单击
                                val newValue = (initialValue + step).coerceIn(minValue, maxValue)
                                if (newValue != initialValue) {
                                    onValueChange(newValue)
                                }
                             }


                            // 释放时移除按压效果
                            interactionSource.emit(PressInteraction.Release(press))

                        } catch (e: CancellationException) {
                            // 协程被取消(通常是手指移出按钮范围或组件销毁)
                             longPressJob?.cancel() // 确保停止
                             // interactionSource 在 clickable 内部会自动处理移除 press 状态
                            //所以下面代码去掉
                            // interactionSource.tryEmit(PressInteraction.Cancel(press))
                        } finally {
                            // 无论如何都确保停止长按任务
                             longPressJob?.cancel()
                        }
                    }
                    // onTap 不需要了,单击逻辑整合进了 onPress
                )
            }
    ) {
        // 调用 content lambda,传递当前值和按压状态,让调用者自定义显示
        content(initialValue, isPressed)
    }
}

3.3 完整代码示例 (应用 HoldableActionButton)

现在你可以这样使用它:

import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.*
import androidx.compose.foundation.Indication
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.indication
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.ui.input.pointer.pointerInput


@Composable
fun CounterScreen() {
    var life by remember { mutableStateOf(20) }
    var poison by remember { mutableStateOf(0) }
    var selectedCounter by remember { mutableStateOf("Life") } // 假设有切换逻辑

    Column(
        modifier = Modifier.fillMaxSize().padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("Selected: $selectedCounter", style = MaterialTheme.typography.headlineSmall)
        Spacer(Modifier.height(16.dp))

        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceEvenly
        ) {
            // 减法按钮
            HoldableActionButton(
                modifier = Modifier.size(100.dp).background(Color.Red.copy(alpha = 0.7f)),
                initialValue = if (selectedCounter == "Life") life else poison,
                step = -1,
                longPressStep = -10,
                onValueChange = { newValue ->
                    if (selectedCounter == "Life") {
                        life = newValue
                    } else {
                        poison = newValue
                    }
                },
                 minValue = 0 // 示例:生命值和毒素不能小于0
            ) { value, isPressed ->
                // 自定义按钮内容
                Column(
                    horizontalAlignment = Alignment.CenterHorizontally,
                    verticalArrangement = Arrangement.Center,
                     modifier = Modifier.fillMaxSize() // 让内容充满 Box
                ) {
                     Text("-", style = MaterialTheme.typography.headlineLarge)
                     Text(
                         if (isPressed) (if(selectedCounter == "Life") life else poison).toString() else "", // 按住时显示当前值(可选)
                          style = MaterialTheme.typography.bodySmall
                     )
                }

            }

            // 显示当前值
             Text(
                 text = when (selectedCounter) {
                     "Life" -> "Life: $life"
                     "Poison" -> "Poison: $poison"
                     else -> ""
                 },
                 style = MaterialTheme.typography.headlineMedium,
                  modifier = Modifier.align(Alignment.CenterVertically)
             )


            // 加法按钮
            HoldableActionButton(
                modifier = Modifier.size(100.dp).background(Color.Green.copy(alpha = 0.7f)),
                 initialValue = if (selectedCounter == "Life") life else poison,
                 step = 1,
                 longPressStep = 10,
                 onValueChange = { newValue ->
                     if (selectedCounter == "Life") {
                         life = newValue
                     } else {
                         poison = newValue
                     }
                 },
                  maxValue = 99 // 示例:上限99
            ) { value, isPressed ->
                 // 自定义按钮内容
                Column(
                    horizontalAlignment = Alignment.CenterHorizontally,
                     verticalArrangement = Arrangement.Center,
                     modifier = Modifier.fillMaxSize() // 让内容充满 Box
                 ) {
                     Text("+", style = MaterialTheme.typography.headlineLarge)
                     Text(
                          if (isPressed) (if(selectedCounter == "Life") life else poison).toString() else "", // 按住时显示当前值(可选)
                          style = MaterialTheme.typography.bodySmall
                      )
                  }
            }
        }

        Spacer(Modifier.height(32.dp))

        // 简单模拟切换计数器类型
         Button(onClick = {
             selectedCounter = if (selectedCounter == "Life") "Poison" else "Life"
         }) {
             Text("Switch Counter")
         }

    }
}

3.4 代码讲解

  1. HoldableActionButton 函数签名 :接收必要的参数,如初始值、步长、延迟、回调,还包括了最小值/最大值限制和内容自定义 lambda。
  2. 状态和作用域rememberCoroutineScope 创建协程作用域,remember { mutableStateOf<Job?>(null) } 用于持有长按任务的 Job,方便取消。MutableInteractionSourcecollectIsPressedAsState 用于标准的按压视觉反馈。
  3. pointerInputdetectTapGestures :这是核心。我们只用了 onPress 回调。
  4. onPress 内部逻辑
    • 记录按下时间 pressStartTime
    • 启动一个 “等待长按” 的协程 pressJob。它先 delay(longPressDelayMillis)
    • 如果延迟结束前没被取消 :说明长按条件满足。启动另一个 “重复执行” 的协程(保存其 JoblongPressJob)。这个协程用 while(isActive) 循环,不断执行 onValueChange(带步长 longPressStep 并使用 coerceIn 限制范围),然后 delay(longPressIntervalMillis)
    • 使用 try-finallytry-catch(CancellationException) 块包围 awaitRelease()
    • awaitRelease() :挂起当前协程,等待手指抬起。
      • 如果正常抬起 (released == true) :这时检查 isLongPressTriggered。如果是 false,说明在长按逻辑启动前就抬手了,执行单击逻辑(带步长 step)。
      • 无论是单击还是长按后的抬起 :都需要确保取消 pressJoblongPressJobfinally 块是处理这个的好地方。
    • 交互状态 (interactionSource) :在按下时 emit(PressInteraction.Press),在释放或取消时 emit(PressInteraction.Release)PressInteraction.Cancel。这里使用 clickable 修饰符简化了这一过程,它会自动处理 interactionSource
  5. 内容 Lambda (content) :允许调用者传入一个 Composable 函数来定义按钮的实际外观,可以根据 isPressed 状态改变样式。
  6. 范围限制 (coerceIn) :在修改值之前,使用 coerceIn(minValue, maxValue) 确保结果不会超出允许的范围。

3.5 进阶玩法与优化

  • 触觉反馈 :可以在 onValueChange 被调用时,或者长按首次触发时,加入 Haptic Feedback(震动反馈),提升用户体验。
    import androidx.compose.ui.platform.LocalHapticFeedback
    // ...
    val haptic = LocalHapticFeedback.current
    // 在 onValueChange 或长按首次触发时调用
    // haptic.performHapticFeedback(HapticFeedbackType.LongPress) // 或其他类型
    
  • 视觉反馈HoldableActionButton 已经使用了 interactionSourcecollectIsPressedAsState。在 content lambda 中可以利用 isPressed 变量来改变背景色、大小或其他视觉元素,给用户明确的按压反馈。
  • 参数化 :将延迟时间、间隔时间、步长都做成参数,使按钮更具通用性。这在上面的示例中已经做到了。
  • 避免重复计算 :如果按钮的计算逻辑比较复杂,确保只在必要时执行。

4. 安全考量

  • 协程管理 :务必确保在按钮不再可见(Composable 离开 Composition)或手势取消/结束后,所有启动的协程(特别是循环执行的 longPressJob)都被正确取消。rememberCoroutineScope 结合 LaunchedEffectpointerInput 的生命周期管理通常能保证这一点,但手动管理 Job 时要格外小心,finally 块和 onDispose(如果使用 DisposableEffect)是确保清理的好地方。
  • 边界值处理coerceIn 很好地处理了数值范围限制,防止了简单的越界问题。如果涉及更复杂的逻辑,要考虑极端情况。
  • 性能 :频繁的 onValueChange 调用和状态更新可能会触发重组。如果遇到性能问题,考虑:
    • 确保 onValueChange lambda 是稳定的(或者使用 rememberUpdatedState 包装它)。
    • 检查重组范围,避免不必要的 UI 更新。
    • 对于非常高性能要求的场景,可能需要更底层的优化。

现在,你应该有了一个功能完善、可配置的长按连续操作按钮了。这个方法比 combinedClickable 稍微复杂一点,但提供了精确控制事件和实现复杂交互所需的灵活性。