By default, all standard Android control types have focus indicators which display when keyboard focus is on a control. However, the default focus indicators are generally low-contrast, which makes the focussed control very hard to identify for keyboard users with low vision. Applying custom focus indicators is one way to better support those users in accordance with the WCAG Success Criterion 2.4.7 Focus Visible and Success Criterion 2.4.13 Focus Appearance (Level AAA).
(Note that using the default focus indicator is in technical conformance with WCAG Success Criterion 2.4.13 Focus Appearance (Level AAA), but it provides a poor user experience for keyboard users.)
There are three major techniques available to customize the focus indicators for controls:
- Apply
Modifier.border()with a focus-state-based color - Apply
Button'sborderproperty with a focus-state-based color - Apply a custom
IndicationusingModifier.indication()orModifier.clickable()
Setting a composable's Modifier.border() property can produce more visible focus indicators.
This technique involves three steps:
- Remember appropriate border color for the current focus state. (And initially use
Color.Transparent, since the composable is not yet focused.) - Use
Modifier.onFocusChanged()to track changes in the focus state and update the border color appropriately. - Use
Modifier.border()to apply an appropriate custom border. (Alternatively,Modifier.background()could be used.)
For example:
// Remember the focus state color
var borderColor by remember {
mutableStateOf(Color.Transparent)
}
Text(
text = "Show terms and conditions",
modifier = Modifier
// Track the focus state and set the appropriate color
.onFocusChanged {
borderColor = if (it.isFocused) Color.Red else Color.Transparent
}
// Apply a border indicator
.border(width = 2.dp, color = borderColor, shape = RoundedCornerShape(4.dp))
.clickable(role = Role.Button) {
// Show terms & conditions...
},
// Apply clickable text styling ...
)Notes:
- Be sure to make the border color distinguishable in both Light and Dark themes.
- But sure to make the border distinguished by more than hue to avoid a failure of WCAG Success Criterion 1.4.1 Use of Color. In the example above, this is done by making the border only appear visible in the focused state.
- See also React to focus.
This technique can also be encapsulated into a reusable Modifier extension function:
@Composable
fun Modifier.visibleFocusBorder(): Modifier {
var borderColor by remember {
mutableStateOf(Color.Transparent)
}
val focusIndicatorColor = colorResource(id = R.color.focus_indicator_outline)
return this
.onFocusChanged {
borderColor = if (it.isFocused) focusIndicatorColor else Color.Transparent
}
.border(2.dp, borderColor, shape = RoundedCornerShape(4.dp))
}Such a Modifier extension function simplifies the application of a custom focus border to a composable. For example, the earlier example Text would become:
Text(
text = "Show terms and conditions",
modifier = Modifier
.visibleFocusBorder()
.clickable(role = Role.Button) {
// Show terms & conditions...
},
)To apply a custom focus indicator to a Button composable, use its border property instead of Modifier.border(). Otherwise, the same steps apply.
For example:
var borderColor by remember {
mutableStateOf(Color.Transparent)
}
TextButton(
onClick = {
// Show terms and conditions...
},
modifier = Modifier
.onFocusChanged {
borderColor = if (it.isFocused) Color.Red else Color.Transparent
},
// Using the border property will allow the focus indicator to conform to the Button's shape
border = BorderStroke(
width = 2.dp,
color = borderColor
)
) {
Text(text = "Show terms and conditions")
}Note: IconButton composables can be handled similarly using an OutlinedIconButton with a border that is only visible when the composable has focus (as above).
Jetpack Compose also allows precise, low-level drawing to be performed when state changes using custom IndicationNodeFactory subclass and a Modifier.Node plus DrawModifierNode subclass. This technique involves the following steps:
- Create a subclass of both
Modifier.NodeandDrawModifierNodethat accepts anInteractionSourceconstructor parameter. This parameter provides a source of focus state events.- Create a
Booleanvar to hold the current focus state value. - Override the
Modifier.Node.onAttach()method and collect the focus events from theInteractionSourceto set the current focus state value. - Override the
ContentDrawScope.drawIndication()method.- Be certain to call
drawContent()in this method. - Use
compose.ui.graphics.drawscopemethods to apply an appropriate focus indicator based on the current focus state value.
- Be certain to call
- Create a
- Create an
IndicationNodeFactorysubclass.- Override the
create()method.- Pass the method's
interactionSourceparameter to the customModifier.NodeplusDrawModifierNodesubclass constructor.
- Pass the method's
- Override the
- Apply this custom
IndicationNodeFactorysubclass to a composable using theModifier.indication()method or by passing it as a parameter toModifier.clickable().
See also React to focus - Implement advanced visual cues and Handling user interactions.
For example:
class CustomFocusIndication : IndicationNodeFactory {
override fun create(interactionSource: InteractionSource): DelegatableNode {
// Return the DrawModifierNode that will perform the focus-state-based drawing.
return CustomFocusIndicationNode(interactionSource)
}
override fun hashCode(): Int = -1
override fun equals(other: Any?) = other === this
}
private class CustomFocusIndicationNode(
private val interactionSource: InteractionSource
) : Modifier.Node(), DrawModifierNode {
private var isFocused = false
override fun onAttach() {
super.onAttach()
coroutineScope.launch {
// Technique: collect the focus start and end Interactions on this composable's
// InteractionSource as a Boolean holding the current focus state value.
val focusInteractions = mutableListOf<FocusInteraction.Focus>()
interactionSource.interactions.collect { interaction ->
when (interaction) {
is FocusInteraction.Focus -> focusInteractions.add(interaction)
is FocusInteraction.Unfocus -> focusInteractions.remove(interaction.focus)
}
isFocused = focusInteractions.isNotEmpty()
}
}
}
override fun ContentDrawScope.draw() {
this@draw.drawContent()
if (isFocused) {
drawRoundRect(
color = Color.Red,
size = size,
cornerRadius = CornerRadius(4.dp.toPx(), 4.dp.toPx()),
style = Stroke(width = 2.dp.toPx()),
alpha = 1.0f
)
}
}
}To layer the custom focus indication on top of the default click ripple and focus background indication, use Modifier.indication():
val interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
Text(
text = "Show terms and conditions",
modifier = Modifier
// Apply the custom focus Indication; clickable will still apply the default ripple indication
.indication(
interactionSource = interactionSource,
indication = VisibleFocusIndication()
)
.clickable(role = Role.Button) {
// Show terms & conditions...
},
// Apply clickable text styling ...
)To replace the default click ripple and focus background indication with the new custom focus indication, use Modifier.clickable with the interactionSource and indication parameters:
val interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
Text(
text = "Show terms and conditions",
modifier = Modifier
// Apply the custom focus Indication, replacing the default ripple indication
.clickable(
interactionSource = interactionSource,
indication = CustomFocusIndication(),
role = Role.Button
) {
// Show terms & conditions...
},
// Apply clickable text styling ...
)Notes:
- The hard-coded text shown in these examples is only used for simplicity. Always use externalized string resource references in actual code.
- The hard-coded colors shown in these examples are only used for simplicity. Color resources or theme-based colors are strongly preferred in actual code. Colors should also respond to Light and Dark theme settings.
Copyright 2024 CVS Health and/or one of its affiliates
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and limitations under the License.