Compose 键盘焦点别乱写!正确姿势只有这一种

前言

Compose 组件默认有 Ripple,但键盘焦点最好单独做。

InteractionSource 已经能拿到 FocusInteraction,再用 IndicationNodeFactory + DrawModifierNode 画一层焦点样式,就能把逻辑收成一个 Modifier。

Ripple 不负责键盘焦点

Material 组件会通过 interactionSource 暴露交互状态。

常见状态包括:

bash 复制代码
FocusInteraction.Focus
FocusInteraction.Unfocus
PressInteraction.Press
PressInteraction.Release
DragInteraction.Start

Ripple 更偏触摸反馈。键盘、方向键、Tab 导航时,需要的是稳定可见的 focus indicator。

直接用 onFocusChanged 加边框也可以:

bash 复制代码
var focused by remember { mutableStateOf(false) }

Button(
    modifier = Modifier
        .onFocusChanged { focused = it.isFocused }
        .border(
            width = if (focused) 3.dp else 0.dp,
            color = if (focused) Color(0xFF005FCC) else Color.Transparent,
            shape = RoundedCornerShape(12.dp)
        ),
    onClick = {}
) {
    Text("Continue")
}

但这种写法很快会散掉。Button、Row、Card、ListItem 都要各写一遍;边框还可能改变布局。更适合的做法是走 Indication

用 DrawModifierNode 画

先写一个 Node,监听同一个 InteractionSource,拿到 focus / unfocus 后决定是否绘制。

bash 复制代码
private class KeyboardFocusNode(
    private val interactionSource: InteractionSource,
    private val color: Color,
) : Modifier.Node(), DrawModifierNode {

    private var focused by mutableStateOf(false)

    override fun onAttach() {
        coroutineScope.launch {
            interactionSource.interactions.collect { interaction ->
                when (interaction) {
                    is FocusInteraction.Focus -> focused = true
                    is FocusInteraction.Unfocus -> focused = false
                }
            }
        }
    }

    override fun ContentDrawScope.draw() {
        drawContent()

        if (!focused) return

        val thickness = 4.dp.toPx()
        drawRect(
            color = color,
            topLeft = Offset(0f, size.height - thickness),
            size = Size(size.width, thickness)
        )
    }
}

几个点:

  • drawContent() 先画原组件

  • • 焦点线后画,避免被内容盖住

  • • 线宽别太细,1.dp 通常不够明显

  • • 颜色不要直接写死在业务组件里

包成 Indication

IndicationNodeFactory 负责创建上面的 Node。

bash 复制代码
private data class KeyboardFocusIndication(
    private val color: Color,
) : IndicationNodeFactory {
    override fun create(interactionSource: InteractionSource): Modifier.Node {
        return KeyboardFocusNode(interactionSource, color)
    }
}

再暴露一个 Modifier:

bash 复制代码
@Composable
fun Modifier.keyboardFocusIndicator(
    interactionSource: MutableInteractionSource,
    color: Color = MaterialTheme.colorScheme.primary,
): Modifier {
    val focusIndication = remember(color) {
        KeyboardFocusIndication(color)
    }
    return indication(interactionSource, focusIndication)
}

核心规则只有一个:组件和 Modifier 必须共用同一个 MutableInteractionSource

Button 用法

Material Button 有 interactionSource 参数,直接传进去。

bash 复制代码
@Composable
fun PrimaryActionButton(
    text: String,
    onClick: () -> Unit,
) {
    val source = remember { MutableInteractionSource() }

    Button(
        onClick = onClick,
        interactionSource = source,
        modifier = Modifier.keyboardFocusIndicator(source)
    ) {
        Text(text)
    }
}

不要这样写:

bash 复制代码
Button(
    interactionSource = remember { MutableInteractionSource() },
    modifier = Modifier.keyboardFocusIndicator(
        remember { MutableInteractionSource() }
    ),
    onClick = {}
) {
    Text("Wrong")
}

两个 source 不相通,Button 收到的 focus 事件不会进入自定义 indication。

Row 用法

设置页里的 Switch Row,焦点一般应该画整行,不是只画右侧 Switch。

bash 复制代码
@Composable
fun SettingsSwitchRow(
    title: String,
    checked: Boolean,
    onCheckedChange: (Boolean) -> Unit,
) {
    val source = remember { MutableInteractionSource() }

    Row(
        modifier = Modifier
            .fillMaxWidth()
            .keyboardFocusIndicator(source)
            .toggleable(
                value = checked,
                onValueChange = onCheckedChange,
                role = Role.Switch,
                interactionSource = source,
                indication = ripple()
            )
            .padding(horizontal = 16.dp, vertical = 12.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        Text(
            text = title,
            modifier = Modifier.weight(1f)
        )
        Switch(
            checked = checked,
            onCheckedChange = null,
            interactionSource = source
        )
    }
}

这里 Row 负责 toggleable,所以焦点线跟着 Row 画满宽度。Switch 只表达 checked 状态。

触摸模式下隐藏

如果只希望键盘模式显示焦点线,可以让 Node 读取 LocalInputModeManager

bash 复制代码
private class KeyboardFocusNode(
    private val interactionSource: InteractionSource,
    private val color: Color,
) : Modifier.Node(),
    DrawModifierNode,
    CompositionLocalConsumerModifierNode {

    private var focused by mutableStateOf(false)

    override fun ContentDrawScope.draw() {
        drawContent()

        val inputMode = currentValueOf(LocalInputModeManager).inputMode
        if (!focused || inputMode != InputMode.Keyboard) return

        val thickness = 4.dp.toPx()
        drawRect(
            color = color,
            topLeft = Offset(0f, size.height - thickness),
            size = Size(size.width, thickness)
        )
    }
}

手机触摸场景可以隐藏。TV、桌面、车机场景可以一直显示。这个策略建议放在设计系统里统一。

颜色单独做 token

焦点颜色不要散在页面里。

bash 复制代码
object AppFocusTokens {
    val Light = Color(0xFF005FCC)
    val Dark = Color(0xFF9CC9FF)
}

@Composable
fun focusIndicatorColor(): Color {
    return if (isSystemInDarkTheme()) {
        AppFocusTokens.Dark
    } else {
        AppFocusTokens.Light
    }
}

使用时:

bash 复制代码
val focusColor = focusIndicatorColor()
Modifier.keyboardFocusIndicator(source, focusColor)

浅色、深色、动态色背景都要看一遍。焦点线是状态,不是装饰色。

最后

Compose 下做键盘焦点指示器,重点是三件事:

  • • 用同一个 MutableInteractionSource

  • • 用 IndicationNodeFactory + DrawModifierNode 统一绘制

  • • 把颜色、线宽、触摸模式策略收进设计系统

这样 Button、Switch Row、Card、List Item 都能复用同一套焦点表现。

#JetpackCompose #Android无障碍 #ComposeUI #Kotlin #Android开发

相关推荐
刮风那天1 小时前
Android ActivityStarter 完整解析
android
liyunlong-java1 小时前
Android 跳转系统相册选取图片/视频/音频/文档(适配全版本权限)
android·gitee·音视频
q20609517101 小时前
文件上传漏洞攻防全解析
android
刮风那天1 小时前
Android 理解requestStartTransition过渡动画
android
流星白龙2 小时前
【MySQL高阶】8.MySQL系统库
android·mysql·adb
Mr.QingBin2 小时前
android Surface绘制状态流转-WindowStateAnimator
android
码云骑士2 小时前
Android 应用启动过程
android
bqliang2 小时前
译 · Jake Wharton 访谈:Android 圈最熟悉的那个名字
android·程序员·开源
三少爷的鞋2 小时前
Android Data 层 Flow 最佳实践:以冷流为基础,按需转热,避免过早共享状态
android