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

0 阅读3分钟

前言

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

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

Image

Ripple 不负责键盘焦点

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

常见状态包括:

FocusInteraction.Focus
FocusInteraction.Unfocus
PressInteraction.Press
PressInteraction.Release
DragInteraction.Start

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

直接用 onFocusChanged 加边框也可以:

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

Image

用 DrawModifierNode 画

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

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。

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

再暴露一个 Modifier:

@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 参数,直接传进去。

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

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

不要这样写:

Button(
    interactionSource = remember { MutableInteractionSource() },
    modifier = Modifier.keyboardFocusIndicator(
        remember { MutableInteractionSource() }
    ),
    onClick = {}
) {
    Text("Wrong")
}

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

Row 用法

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

@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 状态。

Image

触摸模式下隐藏

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

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

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

object AppFocusTokens {
    val Light = Color(0xFF005FCC)
    val Dark = Color(0xFF9CC9FF)
}

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

使用时:

val focusColor = focusIndicatorColor()
Modifier.keyboardFocusIndicator(source, focusColor)

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

Image

最后

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

  • • 用同一个 MutableInteractionSource

  • • 用 IndicationNodeFactory + DrawModifierNode 统一绘制

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

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

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