diff --git a/README.md b/README.md index 10a30bf80d..b1a7a83af6 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Preview functionality (check your application UI without building/running it) fo * [Top level windows management](tutorials/Window_API_new) * [Menu, tray, notifications](tutorials/Tray_Notifications_MenuBar_new) * [Keyboard support](tutorials/Keyboard) + * [Tab focus navigation](tutorials/Tab_Navigation) * [Building native distribution](tutorials/Native_distributions_and_local_execution) * [Signing and notarization](tutorials/Signing_and_notarization_on_macOS) * [Swing interoperability](tutorials/Swing_Integration) diff --git a/tutorials/Tab_Navigation/README.md b/tutorials/Tab_Navigation/README.md new file mode 100644 index 0000000000..7f81f9ed92 --- /dev/null +++ b/tutorials/Tab_Navigation/README.md @@ -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) \ No newline at end of file diff --git a/tutorials/Tab_Navigation/default-tab-nav.gif b/tutorials/Tab_Navigation/default-tab-nav.gif new file mode 100644 index 0000000000..6ecf2900db Binary files /dev/null and b/tutorials/Tab_Navigation/default-tab-nav.gif differ diff --git a/tutorials/Tab_Navigation/focus-switcher.gif b/tutorials/Tab_Navigation/focus-switcher.gif new file mode 100644 index 0000000000..6b56649595 Binary files /dev/null and b/tutorials/Tab_Navigation/focus-switcher.gif differ diff --git a/tutorials/Tab_Navigation/focusable-button.gif b/tutorials/Tab_Navigation/focusable-button.gif new file mode 100644 index 0000000000..b63ca41dcd Binary files /dev/null and b/tutorials/Tab_Navigation/focusable-button.gif differ diff --git a/tutorials/Tab_Navigation/reverse-order.gif b/tutorials/Tab_Navigation/reverse-order.gif new file mode 100644 index 0000000000..b044399080 Binary files /dev/null and b/tutorials/Tab_Navigation/reverse-order.gif differ