Roman Sedaikin
3 years ago
committed by
GitHub
6 changed files with 313 additions and 0 deletions
@ -0,0 +1,312 @@ |
|||||||
|
# Tabbing navigation and keyboard focus |
||||||
|
|
||||||
|
## What is covered |
||||||
|
|
||||||
|
In this tutorial, we will show you how to use tabbing navigation between components via keyboard shortcuts `tab` and `shift + tab`. |
||||||
|
|
||||||
|
## Default `Next/Previous` tabbing navigation |
||||||
|
|
||||||
|
By default, `Next/Previous` tabbed navigation moves focus in composition order (in order of appearance), to see how this works, we can use some of the components that are already focusable by default:`TextField`, `OutlinedTextField`, `BasicTextField`, `CircularProgressIndicator`, `LinearProgressIndicator`. |
||||||
|
|
||||||
|
```kotlin |
||||||
|
import androidx.compose.ui.window.application |
||||||
|
import androidx.compose.ui.window.Window |
||||||
|
import androidx.compose.ui.window.WindowState |
||||||
|
import androidx.compose.ui.window.WindowSize |
||||||
|
import androidx.compose.foundation.layout.Box |
||||||
|
import androidx.compose.foundation.layout.Column |
||||||
|
import androidx.compose.foundation.layout.fillMaxSize |
||||||
|
import androidx.compose.foundation.layout.height |
||||||
|
import androidx.compose.foundation.layout.padding |
||||||
|
import androidx.compose.foundation.layout.Spacer |
||||||
|
import androidx.compose.material.OutlinedTextField |
||||||
|
import androidx.compose.runtime.mutableStateOf |
||||||
|
import androidx.compose.runtime.remember |
||||||
|
import androidx.compose.ui.Alignment |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
import androidx.compose.ui.unit.dp |
||||||
|
|
||||||
|
fun main() = application { |
||||||
|
Window( |
||||||
|
state = WindowState(size = WindowSize(350.dp, 500.dp)), |
||||||
|
onCloseRequest = ::exitApplication |
||||||
|
) { |
||||||
|
Box( |
||||||
|
modifier = Modifier.fillMaxSize(), |
||||||
|
contentAlignment = Alignment.Center |
||||||
|
) { |
||||||
|
Column( |
||||||
|
modifier = Modifier.padding(50.dp) |
||||||
|
) { |
||||||
|
for (x in 1..5) { |
||||||
|
val text = remember { mutableStateOf("") } |
||||||
|
OutlinedTextField( |
||||||
|
value = text.value, |
||||||
|
singleLine = true, |
||||||
|
onValueChange = { text.value = it } |
||||||
|
) |
||||||
|
Spacer(modifier = Modifier.height(20.dp)) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
![default-tab-nav](default-tab-nav.gif) |
||||||
|
|
||||||
|
To make a non-focusable component focusable, you need to apply `Modifier.focusable()` modifier to the component. |
||||||
|
|
||||||
|
```kotlin |
||||||
|
import androidx.compose.ui.window.application |
||||||
|
import androidx.compose.ui.window.Window |
||||||
|
import androidx.compose.ui.window.WindowState |
||||||
|
import androidx.compose.ui.window.WindowSize |
||||||
|
import androidx.compose.material.Button |
||||||
|
import androidx.compose.material.ButtonDefaults |
||||||
|
import androidx.compose.material.MaterialTheme |
||||||
|
import androidx.compose.material.Text |
||||||
|
import androidx.compose.foundation.focusable |
||||||
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState |
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource |
||||||
|
import androidx.compose.foundation.layout.Box |
||||||
|
import androidx.compose.foundation.layout.Column |
||||||
|
import androidx.compose.foundation.layout.fillMaxSize |
||||||
|
import androidx.compose.foundation.layout.height |
||||||
|
import androidx.compose.foundation.layout.padding |
||||||
|
import androidx.compose.foundation.layout.size |
||||||
|
import androidx.compose.foundation.layout.Spacer |
||||||
|
import androidx.compose.runtime.Composable |
||||||
|
import androidx.compose.runtime.mutableStateOf |
||||||
|
import androidx.compose.runtime.remember |
||||||
|
import androidx.compose.ui.Alignment |
||||||
|
import androidx.compose.ui.graphics.Color |
||||||
|
import androidx.compose.ui.graphics.lerp |
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
import androidx.compose.ui.unit.dp |
||||||
|
import androidx.compose.ui.unit.IntSize |
||||||
|
import androidx.compose.ui.input.key.KeyEventType |
||||||
|
import androidx.compose.ui.input.key.type |
||||||
|
import androidx.compose.ui.input.key.key |
||||||
|
import androidx.compose.ui.input.key.Key |
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent |
||||||
|
|
||||||
|
fun main() = application { |
||||||
|
Window( |
||||||
|
state = WindowState(size = WindowSize(350.dp, 450.dp)), |
||||||
|
onCloseRequest = ::exitApplication |
||||||
|
) { |
||||||
|
MaterialTheme( |
||||||
|
colors = MaterialTheme.colors.copy( |
||||||
|
primary = Color(10, 132, 232), |
||||||
|
secondary = Color(150, 232, 150) |
||||||
|
) |
||||||
|
) { |
||||||
|
val clicks = remember { mutableStateOf(0) } |
||||||
|
Box( |
||||||
|
modifier = Modifier.fillMaxSize(), |
||||||
|
contentAlignment = Alignment.Center |
||||||
|
) { |
||||||
|
Column( |
||||||
|
modifier = Modifier.padding(40.dp) |
||||||
|
) { |
||||||
|
Text(text = "Clicks: ${clicks.value}") |
||||||
|
Spacer(modifier = Modifier.height(20.dp)) |
||||||
|
for (x in 1..5) { |
||||||
|
FocusableButton("Button $x", { clicks.value++ }) |
||||||
|
Spacer(modifier = Modifier.height(20.dp)) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class) |
||||||
|
@Composable |
||||||
|
fun FocusableButton( |
||||||
|
text: String = "", |
||||||
|
onClick: () -> Unit = {}, |
||||||
|
size: IntSize = IntSize(200, 35) |
||||||
|
) { |
||||||
|
val keyPressedState = remember { mutableStateOf(false) } |
||||||
|
val interactionSource = remember { MutableInteractionSource() } |
||||||
|
val colors = ButtonDefaults.buttonColors( |
||||||
|
backgroundColor = if (interactionSource.collectIsFocusedAsState().value) { |
||||||
|
if (keyPressedState.value) |
||||||
|
lerp(MaterialTheme.colors.secondary, Color(64, 64, 64), 0.3f) |
||||||
|
else |
||||||
|
MaterialTheme.colors.secondary |
||||||
|
} else { |
||||||
|
MaterialTheme.colors.primary |
||||||
|
} |
||||||
|
) |
||||||
|
Button( |
||||||
|
onClick = onClick, |
||||||
|
interactionSource = interactionSource, |
||||||
|
modifier = Modifier.size(size.width.dp, size.height.dp) |
||||||
|
.onPreviewKeyEvent { |
||||||
|
if ( |
||||||
|
it.key == Key.Enter || |
||||||
|
it.key == Key.Spacebar |
||||||
|
) { |
||||||
|
when (it.type) { |
||||||
|
KeyEventType.KeyDown -> { |
||||||
|
keyPressedState.value = true |
||||||
|
} |
||||||
|
KeyEventType.KeyUp -> { |
||||||
|
keyPressedState.value = false |
||||||
|
onClick.invoke() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
false |
||||||
|
} |
||||||
|
.focusable(interactionSource = interactionSource), |
||||||
|
colors = colors |
||||||
|
) { |
||||||
|
Text(text = text) |
||||||
|
} |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
![focusable-buttons](focusable-button.gif) |
||||||
|
|
||||||
|
## Custom ordering |
||||||
|
To move focus in custom order we need to create a `FocusRequester` and apply the `Modifier.focusOrder` modifier to each component you want to navigate. |
||||||
|
|
||||||
|
- `FocusRequester` sends requests to change focus. |
||||||
|
- `Modifier.focusOrder` is used to specify a custom focus traversal order. |
||||||
|
|
||||||
|
In the example below, we simply create a `FocusRequester` list and create text fields for each `FocusRequester` in the list. Each text field sends a focus request to the previous and next text field in the list when using the `shift + tab` or `tab` keyboard shortcut in reverse order. |
||||||
|
|
||||||
|
```kotlin |
||||||
|
import androidx.compose.ui.window.application |
||||||
|
import androidx.compose.ui.window.Window |
||||||
|
import androidx.compose.ui.window.WindowState |
||||||
|
import androidx.compose.ui.window.WindowSize |
||||||
|
import androidx.compose.foundation.layout.Box |
||||||
|
import androidx.compose.foundation.layout.Column |
||||||
|
import androidx.compose.foundation.layout.fillMaxSize |
||||||
|
import androidx.compose.foundation.layout.height |
||||||
|
import androidx.compose.foundation.layout.padding |
||||||
|
import androidx.compose.foundation.layout.Spacer |
||||||
|
import androidx.compose.material.OutlinedTextField |
||||||
|
import androidx.compose.runtime.mutableStateOf |
||||||
|
import androidx.compose.runtime.remember |
||||||
|
import androidx.compose.ui.focus.FocusRequester |
||||||
|
import androidx.compose.ui.focus.focusOrder |
||||||
|
import androidx.compose.ui.Alignment |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
import androidx.compose.ui.unit.dp |
||||||
|
|
||||||
|
fun main() = application { |
||||||
|
Window( |
||||||
|
state = WindowState(size = WindowSize(350.dp, 500.dp)), |
||||||
|
onCloseRequest = ::exitApplication |
||||||
|
) { |
||||||
|
val itemsList = remember { List(5) { FocusRequester() } } |
||||||
|
Box( |
||||||
|
modifier = Modifier.fillMaxSize(), |
||||||
|
contentAlignment = Alignment.Center |
||||||
|
) { |
||||||
|
Column( |
||||||
|
modifier = Modifier.padding(50.dp) |
||||||
|
) { |
||||||
|
itemsList.forEachIndexed { index, item -> |
||||||
|
val text = remember { mutableStateOf("") } |
||||||
|
OutlinedTextField( |
||||||
|
value = text.value, |
||||||
|
singleLine = true, |
||||||
|
onValueChange = { text.value = it }, |
||||||
|
modifier = Modifier.focusOrder(item) { |
||||||
|
// reverse order |
||||||
|
next = if (index - 1 < 0) itemsList.last() else itemsList[index - 1] |
||||||
|
previous = if (index + 1 == itemsList.size) itemsList.first() else itemsList[index + 1] |
||||||
|
} |
||||||
|
) |
||||||
|
Spacer(modifier = Modifier.height(20.dp)) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
![reverse-order](reverse-order.gif) |
||||||
|
|
||||||
|
## Making component focused |
||||||
|
|
||||||
|
To make a component focused, we need to create a `FocusRequester` and apply the `Modifier.focusRequester` modifier to the component you want to focus on. With `FocusRequester`, we can request focus, as in the example below: |
||||||
|
|
||||||
|
```kotlin |
||||||
|
import androidx.compose.ui.window.application |
||||||
|
import androidx.compose.ui.window.Window |
||||||
|
import androidx.compose.ui.window.WindowState |
||||||
|
import androidx.compose.ui.window.WindowSize |
||||||
|
import androidx.compose.foundation.focusable |
||||||
|
import androidx.compose.foundation.layout.Box |
||||||
|
import androidx.compose.foundation.layout.Column |
||||||
|
import androidx.compose.foundation.layout.fillMaxSize |
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth |
||||||
|
import androidx.compose.foundation.layout.height |
||||||
|
import androidx.compose.foundation.layout.padding |
||||||
|
import androidx.compose.foundation.layout.Spacer |
||||||
|
import androidx.compose.material.Button |
||||||
|
import androidx.compose.material.OutlinedTextField |
||||||
|
import androidx.compose.material.Text |
||||||
|
import androidx.compose.runtime.mutableStateOf |
||||||
|
import androidx.compose.runtime.remember |
||||||
|
import androidx.compose.ui.focus.FocusRequester |
||||||
|
import androidx.compose.ui.focus.focusRequester |
||||||
|
import androidx.compose.ui.Alignment |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
import androidx.compose.ui.unit.dp |
||||||
|
|
||||||
|
fun main() = application { |
||||||
|
Window( |
||||||
|
state = WindowState(size = WindowSize(350.dp, 450.dp)), |
||||||
|
onCloseRequest = ::exitApplication |
||||||
|
) { |
||||||
|
val buttonFocusRequester = remember { FocusRequester() } |
||||||
|
val textFieldFocusRequester = remember { FocusRequester() } |
||||||
|
val focusState = remember { mutableStateOf(false) } |
||||||
|
val text = remember { mutableStateOf("") } |
||||||
|
Box( |
||||||
|
modifier = Modifier.fillMaxSize(), |
||||||
|
contentAlignment = Alignment.Center |
||||||
|
) { |
||||||
|
Column( |
||||||
|
modifier = Modifier.padding(50.dp) |
||||||
|
) { |
||||||
|
Button( |
||||||
|
onClick = { |
||||||
|
focusState.value = !focusState.value |
||||||
|
if (focusState.value) { |
||||||
|
textFieldFocusRequester.requestFocus() |
||||||
|
} else { |
||||||
|
buttonFocusRequester.requestFocus() |
||||||
|
} |
||||||
|
}, |
||||||
|
modifier = Modifier.fillMaxWidth() |
||||||
|
.focusRequester(buttonFocusRequester) |
||||||
|
.focusable() |
||||||
|
) { |
||||||
|
Text(text = "Focus switcher") |
||||||
|
} |
||||||
|
Spacer(modifier = Modifier.height(20.dp)) |
||||||
|
OutlinedTextField( |
||||||
|
value = text.value, |
||||||
|
singleLine = true, |
||||||
|
onValueChange = { text.value = it }, |
||||||
|
modifier = Modifier |
||||||
|
.focusRequester(textFieldFocusRequester) |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
![reverse-order](focus-switcher.gif) |
After Width: | Height: | Size: 373 KiB |
After Width: | Height: | Size: 465 KiB |
After Width: | Height: | Size: 398 KiB |
After Width: | Height: | Size: 310 KiB |
Loading…
Reference in new issue