diff --git a/compose/snippets/src/main/AndroidManifest.xml b/compose/snippets/src/main/AndroidManifest.xml index c9b15e6c..30e91caf 100644 --- a/compose/snippets/src/main/AndroidManifest.xml +++ b/compose/snippets/src/main/AndroidManifest.xml @@ -55,6 +55,9 @@ android:exported="false"/> + + + ApplyPolygonAsClipImage() Destination.SharedElementExamples -> PlaceholderSizeAnimated_Demo() Destination.PagerExamples -> PagerExamples() + Destination.FocusExample -> FocusExample() } } } diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/navigation/Destination.kt b/compose/snippets/src/main/java/com/example/compose/snippets/navigation/Destination.kt index 78396390..3c163947 100644 --- a/compose/snippets/src/main/java/com/example/compose/snippets/navigation/Destination.kt +++ b/compose/snippets/src/main/java/com/example/compose/snippets/navigation/Destination.kt @@ -24,7 +24,8 @@ enum class Destination(val route: String, val title: String) { ScreenshotExample("screenshotExample", "Screenshot Examples"), ShapesExamples("shapesExamples", "Shapes Examples"), SharedElementExamples("sharedElement", "Shared elements"), - PagerExamples("pagerExamples", "Pager examples") + PagerExamples("pagerExamples", "Pager examples"), + FocusExample("focusExample", "Keyboard Focus"), } // Enum class for compose components navigation screen. @@ -49,5 +50,5 @@ enum class TopComponentsDestination(val route: String, val title: String) { MenusExample("menusExamples", "Menus"), TooltipExamples("tooltipExamples", "Tooltips"), NavigationDrawerExamples("navigationDrawerExamples", "Navigation drawer"), - SegmentedButtonExamples("segmentedButtonExamples", "Segmented button") + SegmentedButtonExamples("segmentedButtonExamples", "Segmented button"), } diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/FocusExample.kt b/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/FocusExample.kt new file mode 100644 index 00000000..b9a5e4db --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/FocusExample.kt @@ -0,0 +1,153 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * 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 + * + * https://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. + */ + +package com.example.compose.snippets.touchinput.focus + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ListItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController + +enum class FocusExample( + val route: String, + val title: String +) { + Home("home", "Home"), + FocusTraversalOrder("focusTraversal", "Focus Traversal Order"), + InitialFocus("initialFocus", "Initial Focus"), + InitialFocusWithScrollableContainer( + "initialFocusWithScrollableContainer", + "Initial Focus with Scrollable Container" + ), + InitialFocusEnablingContentReload( + "initialFocusEnablingContentReload", + "Initial Focus Enabling Content Reload" + ), + FocusRestoration("focusRestoration", "Focus Restoration"), + FocusInListDetailLayout("focusInListDetailLayout", "Focus In List-Detail Layout"), +} + +@Composable +fun FocusExample( + navController: NavHostController = rememberNavController() +) { + NavHost(navController, startDestination = FocusExample.Home.route) { + composable(FocusExample.Home.route) { + val entries = remember { + listOf( + FocusExample.FocusTraversalOrder, + FocusExample.InitialFocus, + FocusExample.InitialFocusWithScrollableContainer, + FocusExample.InitialFocusEnablingContentReload, + FocusExample.FocusRestoration, + FocusExample.FocusInListDetailLayout, + ) + } + FocusExampleScreen(entries) { + navController.navigate(it.route) + } + } + composable(FocusExample.FocusTraversalOrder.route) { + FocusTraversalScreen(modifier = Modifier.padding(16.dp)) + } + composable(FocusExample.InitialFocus.route) { + InitialFocusScreen() + } + composable(FocusExample.InitialFocusWithScrollableContainer.route) { + InitialFocusWithScrollableContainerScreen() + } + composable(FocusExample.InitialFocusEnablingContentReload.route) { + InitialFocusWithContentReloadScreen() + } + composable(FocusExample.FocusRestoration.route) { + FocusRestorationScreen() + } + composable(FocusExample.FocusInListDetailLayout.route) { + FocusRestorationInListDetailScreen() + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun FocusExampleScreen( + examples: List, + navigateToDetails: (FocusExample) -> Unit, +) { + val focusRequester = remember { FocusRequester() } + + Box(contentAlignment = Alignment.TopCenter) { + ExampleList( + examples = examples, + showDetails = { + // Save the focused child before navigating to the detail pane + focusRequester.saveFocusedChild() + navigateToDetails(it) + }, + modifier = Modifier + .widthIn(max = 600.dp) + .focusRequester(focusRequester) + ) + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun ExampleList( + examples: List, + showDetails: (FocusExample) -> Unit = {}, + modifier: Modifier = Modifier, + + ) { + val context = LocalContext.current + // [START android_compose_touchinput_focus_restoration] + LazyColumn( + modifier = modifier.focusRestorer() + ) { + items(examples) { + ListItem( + headlineContent = { Text(it.title) }, + modifier = Modifier.clickable { + showDetails(it) + } + ) + } + } + // [END android_compose_touchinput_focus_restoration] +} diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/FocusRestorationInListDetailLayoutSnippet.kt b/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/FocusRestorationInListDetailLayoutSnippet.kt new file mode 100644 index 00000000..3e6d8839 --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/FocusRestorationInListDetailLayoutSnippet.kt @@ -0,0 +1,236 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * 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 + * + * https://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. + */ + +package com.example.compose.snippets.touchinput.focus + +import android.os.Parcelable +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Button +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.layout.AnimatedPane +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole +import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior +import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.unit.dp +import kotlinx.parcelize.Parcelize + +@Parcelize +class CatalogItem(val value: Int) : Parcelable { + companion object { + fun createCatalog(items: Int, startValue: Int = 1): List { + val lastValue = startValue + items + return (startValue..lastValue).map { CatalogItem(it) } + } + } +} + +@Composable +fun FocusRestorationInListDetailScreen( + modifier: Modifier = Modifier +) { + + val catalogData = remember { + CatalogItem.createCatalog(32) + } + + FocusRestorationInListDetail(catalogData, modifier) +} + +@OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalComposeUiApi::class) +// [START android_compose_touchinput_focus_restoration_listdetail] +@Composable +fun FocusRestorationInListDetail(catalogData: List, modifier: Modifier = Modifier) { + val threePaneScaffoldNavigator = rememberListDetailPaneScaffoldNavigator() + val listState = rememberLazyListState() + val focusRequester = remember { FocusRequester() } + + // Remember the last selected item in the list pane + // to specify the list item to be focused when users back from the details pane. + var lastSelectedCatalogItem by remember { mutableStateOf(catalogData.first()) } + + // Flag indicating that it is safe to request focus on the list pane. + var isListPaneVisible by remember { mutableStateOf(false) } + + BackHandler(threePaneScaffoldNavigator.canNavigateBack(BackNavigationBehavior.PopLatest)) { + threePaneScaffoldNavigator.navigateBack(BackNavigationBehavior.PopLatest) + } + + ListDetailPaneScaffold( + value = threePaneScaffoldNavigator.scaffoldValue, + directive = threePaneScaffoldNavigator.scaffoldDirective, + listPane = { + AnimatedPane { + // ListPane implements the list pane. + // showDetails function is called when the user select a list item. + ListPane( + catalogData = catalogData, + state = listState, + initialFocusItem = lastSelectedCatalogItem, + showDetails = { catalogItem -> + // Update lastSelectedCatalogItem with the catalogItem object + // associated with the clicked list item. + lastSelectedCatalogItem = catalogItem + + // Save the focused child in the ListPane + // so that the component can restore focus + // when users moving focus to ListPane in two-pane layout. + focusRequester.saveFocusedChild() + + // Show the details of the catalogItem value in the detail pane. + threePaneScaffoldNavigator.navigateTo( + ListDetailPaneScaffoldRole.Detail, + catalogItem + ) + }, + modifier = Modifier + // Associate focusRequester value with the ListPane composable. + .focusRequester(focusRequester) + // Set true to isListPaneVisible variable as ListPane composable is visible. + .onPlaced { isListPaneVisible = true } + ) + DisposableEffect(Unit) { + // ListPane is removed from the composition when the app is in single pane layout. + // Set isSafeToRequestFocus to false so that ListPane gets focused + // when it is displayed by users' back action. + onDispose { isListPaneVisible = false } + } + } + }, + detailPane = { + AnimatedPane { + val catalogItem = threePaneScaffoldNavigator.currentDestination?.content + if (catalogItem != null) { + DetailsPane(catalogItem) + } + } + }, + modifier = modifier + ) + + LaunchedEffect(isListPaneVisible) { + if (isListPaneVisible) { + val catalogItemIndex = catalogData.indexOf(lastSelectedCatalogItem) + if (catalogItemIndex >= 0) { + // Ensure the ListItem for the last selected item is visible + listState.animateScrollToItem(catalogItemIndex) + } + focusRequester.requestFocus() + } + } +} +// [END android_compose_touchinput_focus_restoration_listdetail] + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun ListPane( + catalogData: List, + showDetails: (CatalogItem) -> Unit, + modifier: Modifier = Modifier, + state: LazyListState = rememberLazyListState(), + initialFocusItem: CatalogItem? = null, +) { + val initialFocus = remember { FocusRequester() } + val columnModifier = if (catalogData.isEmpty()) { + modifier.focusRestorer { + initialFocus + } + } else { + modifier.focusRestorer() + } + + LazyColumn( + modifier = columnModifier, + state = state, + ) { + items(catalogData) { + val itemModifier = if (it == initialFocusItem) { + Modifier.focusRequester(initialFocus) + } else { + Modifier + } + ListItem( + headlineContent = { + Text("Item ${it.value}") + }, + modifier = itemModifier.clickable { showDetails(it) }, + ) + } + } +} + +@Composable +private fun DetailsPane( + catalogItem: CatalogItem, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text("Item ${catalogItem.value}", style = MaterialTheme.typography.displayMedium) + Button( + onClick = {}, + modifier = Modifier.initialFocus() + ) { + Text("Click me") + } + } +} + +fun FocusRequester.tryRequestFocus(): Result { + try { + requestFocus() + } catch (e: IllegalStateException) { + return Result.failure(e) + } + return Result.success(Unit) +} + +@Composable +fun Modifier.initialFocus(focusRequester: FocusRequester = remember { FocusRequester() }): Modifier { + var isSafe by remember { mutableStateOf(false) } + + LaunchedEffect(isSafe) { + if (isSafe) { + focusRequester.tryRequestFocus() + } + } + return this.focusRequester(focusRequester).onPlaced { isSafe = true } +} diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/FocusRestorationSnippets.kt b/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/FocusRestorationSnippets.kt new file mode 100644 index 00000000..0548de8c --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/FocusRestorationSnippets.kt @@ -0,0 +1,286 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * 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 + * + * https://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. + */ + +package com.example.compose.snippets.touchinput.focus + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +data class Section(val name: String, val catalog: List) + +class FocusRestorationScreenViewModel : ViewModel() { + + companion object { + private val sectionListA = listOf( + Section("Section A", CatalogItem.createCatalog(16)), + Section("Section B", CatalogItem.createCatalog(8, startValue = 18)), + Section("Section C", CatalogItem.createCatalog(16, startValue = 27)), + ) + + private val sectionListB = listOf( + Section("Section D", CatalogItem.createCatalog(8, startValue = 100)), + Section("Section F", CatalogItem.createCatalog(16, startValue = 109)), + Section("Section E", CatalogItem.createCatalog(8, startValue = 126)), + ) + + private val sectionSet = listOf(sectionListA, sectionListB) + } + + private val currentSet = MutableStateFlow(0) + + val sections = currentSet.map { + sectionSet[it] + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) + + fun nextPage() { + currentSet.value = (currentSet.value + 1) % sectionSet.size + } +} + +@Composable +fun FocusRestorationScreen( + modifier: Modifier = Modifier, + coroutineScope: CoroutineScope = rememberCoroutineScope(), + focusRestorationScreenViewModel: FocusRestorationScreenViewModel = viewModel() +) { + val sections by focusRestorationScreenViewModel.sections.collectAsStateWithLifecycle() + val state = rememberLazyListState() + val focusRequester = remember { FocusRequester() } + val scrollToTop = remember { + { + coroutineScope.launch { + state.scrollToItem(0) + focusRequester.requestFocus() + } + } + } + + CatalogWithSection( + sections = sections, + state = state, + reload = { + focusRestorationScreenViewModel.nextPage() + scrollToTop() + }, + scrollToTop = { scrollToTop() }, + modifier = modifier.focusRequester(focusRequester) + ) +} + +@Composable +private fun CatalogWithSection( + sections: List
, + reload: () -> Unit, + scrollToTop: () -> Unit, + modifier: Modifier = Modifier, + state: LazyListState = rememberLazyListState(), +) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(32.dp), + state = state, + modifier = modifier + ) { + items(sections) { + CatalogSection(it) + } + item { + Controls( + reload = reload, + scrollToTop = scrollToTop, + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun CatalogSection( + section: Section, + modifier: Modifier = Modifier, + horizontalOffset: Dp = 16.dp, + coroutineScope: CoroutineScope = rememberCoroutineScope() +) { + val bringIntoViewRequester = remember { BringIntoViewRequester() } + + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier + .bringIntoViewRequester(bringIntoViewRequester) + .onFocusChanged { focusState -> + // Bring the Column into view port when any CatalogItemCard get focused, + // so that users can see the section title and cards at the same time. + if (focusState.hasFocus) { + coroutineScope.launch { + bringIntoViewRequester.bringIntoView() + } + } + }, + ) { + Text( + section.name, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = horizontalOffset) + ) + SectionCatalog(section.catalog) + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun SectionCatalog( + catalog: List, + modifier: Modifier = Modifier, + horizontalOffset: Dp = 16.dp, +) { +// [START android_compose_touchinput_focus_restoration_manually] + val focusRequester = remember(catalog) { FocusRequester() } + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = horizontalOffset), + modifier = modifier + .focusRequester(focusRequester) + .focusProperties { + exit = { + focusRequester.saveFocusedChild() + FocusRequester.Default + } + enter = { + if (focusRequester.restoreFocusedChild()) { + FocusRequester.Cancel + } else { + FocusRequester.Default + } + } + } + ) { + items(catalog) { + CatalogItemCard(it, modifier = Modifier.width(128.dp)) + } + } +// [END android_compose_touchinput_focus_restoration_manually] +} + +@Composable +private fun CatalogItemCard( + catalogItem: CatalogItem, + modifier: Modifier = Modifier, + onClick: () -> Unit = {} +) { + Card(onClick = onClick, modifier = modifier.aspectRatio(9f / 16f)) { + Text("${catalogItem.value}", modifier = Modifier.padding(16.dp)) + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun Controls( + reload: () -> Unit, + scrollToTop: () -> Unit, + modifier: Modifier = Modifier, +) { + // [START android_compose_touchinput_focus_restoration_with_row] + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .focusRestorer() + .focusGroup() + ) { + BackToTopCard(onClick = scrollToTop, modifier = Modifier.width(128.dp)) + ReloadCard(onClick = reload, modifier = Modifier.width(128.dp)) + } + // [END android_compose_touchinput_focus_restoration_with_row] +} + +@Composable +private fun ReloadCard( + onClick: () -> Unit = {}, + modifier: Modifier = Modifier +) { + SquareCard(modifier = modifier, onClick = onClick) { + Text("Reload") + } +} + +@Composable +private fun BackToTopCard( + onClick: () -> Unit = {}, + modifier: Modifier = Modifier +) { + SquareCard(modifier = modifier, onClick = onClick) { + Text("To top") + } +} + +@Composable +private fun SquareCard( + onClick: () -> Unit = {}, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit +) { + Card(onClick = onClick, modifier = modifier.aspectRatio(1f)) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + content = content + ) + } +} diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/FocusSnippets.kt b/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/FocusSnippets.kt index 82b5b2d1..341ffa9e 100644 --- a/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/FocusSnippets.kt +++ b/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/FocusSnippets.kt @@ -18,8 +18,10 @@ package com.example.compose.snippets.touchinput.focus +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Indication import androidx.compose.foundation.IndicationInstance +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -28,24 +30,37 @@ import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi @@ -59,7 +74,6 @@ import androidx.compose.ui.focus.FocusRequester.Companion.Default import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color.Companion.Blue import androidx.compose.ui.graphics.Color.Companion.Green import androidx.compose.ui.graphics.Color.Companion.Red @@ -71,13 +85,211 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun FocusTraversalScreen( + modifier: Modifier = Modifier +) { + val tabs = listOf("Layout and traversal", "Focus properties", "Focus group") + val focusRequesters = remember { tabs.map { FocusRequester() } } + var selectedTabIndex by rememberSaveable { mutableIntStateOf(0) } + var moveFocusToContent by remember { mutableStateOf(false) } + + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(32.dp) + ) { + TabRow( + selectedTabIndex = selectedTabIndex, + modifier = Modifier + .focusProperties { + enter = { focusRequesters[selectedTabIndex] } + } + .focusGroup() + ) { + tabs.zip(focusRequesters).forEachIndexed { index, (label, focusRequester) -> + Tab( + selected = index == selectedTabIndex, + onClick = { + moveFocusToContent = true + selectedTabIndex = index + }, + text = { Text(label) }, + modifier = Modifier.focusRequester(focusRequester) + ) + } + } + when (selectedTabIndex) { + 0 -> FocusTraversal(requestFocusOnLoad = moveFocusToContent) + 1 -> FocusProperties(requestFocusOnLoad = moveFocusToContent) + 2 -> FocusGroup(requestFocusOnLoad = moveFocusToContent) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun FocusTraversal( + modifier: Modifier = Modifier, + verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(48.dp), + scrollState: ScrollState = rememberScrollState(), + focusRequester: FocusRequester = remember { FocusRequester() }, + requestFocusOnLoad: Boolean = false +) { + Container( + modifier = modifier, + verticalArrangement = verticalArrangement, + scrollState = scrollState, + focusRequester = focusRequester, + requestFocusOnLoad = requestFocusOnLoad + ) { + Title("Focus traversal order is irrelevant to layout order") + + Section(title = "Two rows in a column") { + BasicSample(contentAlignment = Alignment.TopStart) + } + + Section(title = "Two columns in a row") { + BasicSample2(contentAlignment = Alignment.TopStart) + } + + Section(title = "Element with offset") { + BasicSample3(contentAlignment = Alignment.TopStart) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun FocusProperties( + modifier: Modifier = Modifier, + verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(48.dp), + scrollState: ScrollState = rememberScrollState(), + focusRequester: FocusRequester = remember { FocusRequester() }, + requestFocusOnLoad: Boolean = false +) { + Container( + modifier = modifier, + verticalArrangement = verticalArrangement, + scrollState = scrollState, + focusRequester = focusRequester, + requestFocusOnLoad = requestFocusOnLoad + ) { + Title("FocusProperties override default traversal order") + + Section(title = "Default traversal order") { + DefaultTraversalOrder() + } + + Section(title = "Override default traversal order") { + OverrideDefaultTraversalOrder() + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun FocusGroup( + modifier: Modifier = Modifier, + verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(48.dp), + scrollState: ScrollState = rememberScrollState(), + focusRequester: FocusRequester = remember { FocusRequester() }, + requestFocusOnLoad: Boolean = false +) { + Container( + modifier = modifier, + verticalArrangement = verticalArrangement, + scrollState = scrollState, + focusRequester = focusRequester, + requestFocusOnLoad = requestFocusOnLoad + ) { + Title("FocusGroup enables to handle enter and exit") + + Section(title = "Without focus group") { + WithoutFocusGroup() + } + + Section(title = "Focus moves to the second item") { + WithFocusGroup() + } + } +} + +@Composable +private fun Container( + modifier: Modifier = Modifier, + verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(48.dp), + scrollState: ScrollState = rememberScrollState(), + focusRequester: FocusRequester = remember { FocusRequester() }, + requestFocusOnLoad: Boolean = false, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = modifier + .verticalScroll(scrollState) + .focusRequester(focusRequester) + .focusGroup(), + verticalArrangement = verticalArrangement, + content = content + ) + LaunchedEffect(Unit) { + if (requestFocusOnLoad) { + focusRequester.requestFocus() + } + } +} + +@Composable +private fun Title( + text: String, + modifier: Modifier = Modifier, + style: TextStyle = MaterialTheme.typography.displaySmall +) { + Text(text = text, modifier = modifier, style = style) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun Section( + title: String, + modifier: Modifier = Modifier, + verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(8.dp), + style: TextStyle = MaterialTheme.typography.titleLarge, + bringIntoViewRequester: BringIntoViewRequester = remember { BringIntoViewRequester() }, + coroutineScope: CoroutineScope = rememberCoroutineScope(), + content: @Composable () -> Unit, +) { + Column( + modifier = modifier + .bringIntoViewRequester(bringIntoViewRequester) + .onFocusChanged { focusState -> + if (focusState.hasFocus) { + coroutineScope.launch { + bringIntoViewRequester.bringIntoView() + } + } + }, + verticalArrangement = verticalArrangement + ) { + Text(title, style = style) + content() + } +} @Preview @Composable -private fun BasicSample() { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { +private fun BasicSample( + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.Center +) { + Box(modifier = modifier, contentAlignment = contentAlignment) { // [START android_compose_touchinput_focus_horizontal] Column { Row { @@ -95,8 +307,11 @@ private fun BasicSample() { @Preview @Composable -private fun BasicSample2() { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { +private fun BasicSample2( + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.Center +) { + Box(modifier = modifier, contentAlignment = contentAlignment) { // [START android_compose_touchinput_focus_vertical] Row { Column { @@ -112,9 +327,31 @@ private fun BasicSample2() { } } -@Preview @Composable -fun OverrideDefaultOrder() { +private fun BasicSample3( + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.Center +) { + Box(modifier = modifier, contentAlignment = contentAlignment) { + // [START android_compose_touchinput_focus_vertical] + Row { + Column { + TextButton( + onClick = { }, + modifier = Modifier.offset(x = 300.dp) + ) { Text("First field") } + TextButton({ }) { Text("Second field") } + } + Column { + TextButton({ }) { Text("Third field") } + TextButton({ }) { Text("Fourth field") } + } + } + } +} + +@Composable +private fun DefaultTraversalOrder() { // [START android_compose_touchinput_focus_override_refs] val (first, second, third, fourth) = remember { FocusRequester.createRefs() } // [END android_compose_touchinput_focus_override_refs] @@ -131,6 +368,12 @@ fun OverrideDefaultOrder() { } } // [END android_compose_touchinput_focus_override] +} + +@Preview +@Composable +private fun OverrideDefaultTraversalOrder() { + val (first, second, third, fourth) = remember { FocusRequester.createRefs() } // [START android_compose_touchinput_focus_override_use] Column { @@ -194,8 +437,51 @@ fun OverrideTwoDimensionalOrder() { // [END android_compose_touchinput_focus_override_2d] } +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun WithoutFocusGroup() { + // [START android_compose_touchinput_focus_without_focus_group] + val secondItem = remember { FocusRequester() } + Row( + modifier = Modifier.focusProperties { + enter = { secondItem } + } + ) { + + TextButton(onClick = {}) { Text("First") } + TextButton(onClick = {}, modifier = Modifier.focusRequester(secondItem)) { Text("Second") } + TextButton(onClick = {}) { Text("Third") } + TextButton(onClick = {}) { Text("Fourth") } + + } + // [END android_compose_touchinput_focus_without_focus_group] +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun WithFocusGroup() { + // [START android_compose_touchinput_focus_without_focus_group] + val secondItem = remember { FocusRequester() } + Row( + modifier = Modifier + .focusProperties { + enter = { secondItem } + } + .focusGroup() + ) { + + TextButton(onClick = {}) { Text("First") } + TextButton(onClick = {}, modifier = Modifier.focusRequester(secondItem)) { Text("Second") } + TextButton(onClick = {}) { Text("Third") } + TextButton(onClick = {}) { Text("Fourth") } + + } + // [END android_compose_touchinput_focus_without_focus_group] +} + + @Composable -private fun FocusGroup() { +private fun FocusGroupInLazyVerticalGrid() { @Composable fun FilterChipA() { @@ -424,7 +710,7 @@ private fun FocusAdvancing() { @Composable private fun ReactToFocus() { // [START android_compose_touchinput_focus_react] - var color by remember { mutableStateOf(Color.White) } + var color by remember { mutableStateOf(White) } Card( modifier = Modifier .onFocusChanged { @@ -442,7 +728,7 @@ private class MyHighlightIndicationInstance(isEnabledState: State) : override fun ContentDrawScope.drawIndication() { drawContent() if (isEnabled) { - drawRect(size = size, color = Color.White, alpha = 0.2f) + drawRect(size = size, color = White, alpha = 0.2f) } } } @@ -452,7 +738,7 @@ private class MyHighlightIndicationInstance(isEnabledState: State) : class MyHighlightIndication : Indication { @Composable override fun rememberUpdatedInstance(interactionSource: InteractionSource): - IndicationInstance { + IndicationInstance { val isFocusedState = interactionSource.collectIsFocusedAsState() return remember(interactionSource) { MyHighlightIndicationInstance(isEnabledState = isFocusedState) diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/InitialFocusSnippets.kt b/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/InitialFocusSnippets.kt new file mode 100644 index 00000000..6934d942 --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/InitialFocusSnippets.kt @@ -0,0 +1,216 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * 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 + * + * https://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. + */ + +package com.example.compose.snippets.touchinput.focus + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Card +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +// [START android_compose_touchinput_initialfocus_basics] +@Composable +fun InitialFocusScreen( + onCardClick: (String) -> Unit = {} +) { + // Remember the FocusRequester object + val initialFocus = remember { FocusRequester() } + + Row { + Card( + onClick = { onCardClick("Card 1") }, + // Associate the card with the FocusRequester object. + modifier = Modifier.focusRequester(initialFocus) + ) { + Text("Card 1", modifier = Modifier.padding(16.dp)) + } + Card(onClick = { onCardClick("Card 2") }) { + Text("Card 2", modifier = Modifier.padding(16.dp)) + } + Card(onClick = { onCardClick("Card 3") }) { + Text("Card 3", modifier = Modifier.padding(16.dp)) + } + } + + LaunchedEffect(Unit) { + // Request focus on the first card. + initialFocus.requestFocus() + } +} +// [END android_compose_touchinput_initialfocus_basics] + +class InitialFocusEnablingContentReloadViewModel( + private val pageSize: Int = 16 +) : ViewModel() { + + private val itemIndex = MutableStateFlow(0) + + val cardData = itemIndex.map { + (it..it + pageSize).map { index -> + "Card $index" + } + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + fun reload() { + nextPage() + } + + private fun nextPage() { + itemIndex.value += pageSize + } +} + +@Composable +fun InitialFocusWithScrollableContainerScreen( + viewModel: InitialFocusEnablingContentReloadViewModel = viewModel(), + onCardClick: (String) -> Unit = {} +) { + val cardData by viewModel.cardData.collectAsStateWithLifecycle() + InitialFocusWithScrollableContainer(cardData, onCardClick) +} + +// [START android_compose_touchinput_initialfocus_with_scrollable_container] +@Composable +fun InitialFocusWithScrollableContainer( + cardData: List, + onCardClick: (String) -> Unit = {} +) { + val initialFocus = remember { FocusRequester() } + + // Flag to determine if it is safe to set initial focus or not. + var isSafeToSetInitialFocus by remember { + mutableStateOf(false) + } + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(horizontal = 32.dp), + ) { + itemsIndexed(cardData) { index, item -> + val cardModifier = when (index) { + // Associate the FocusRequester object with the first card + 0 -> + Modifier + .focusRequester(initialFocus) + .onGloballyPositioned { + // Set the flag true if the element in the viewport + isSafeToSetInitialFocus = true + } + + else -> Modifier + } + Card( + onClick = { onCardClick(item) }, + modifier = cardModifier + ) { + Text(item, modifier = Modifier.padding(16.dp)) + } + } + } + + // The flag is the key to trigger the coroutine to set initial focus. + LaunchedEffect(isSafeToSetInitialFocus) { + // Your app should set initial focus only if the UI element is in viewport + if (isSafeToSetInitialFocus) { + initialFocus.requestFocus() + } + } +} +// [END android_compose_touchinput_initialfocus_with_scrollable_container] + +// [START android_compose_touchinput_initialfocus_with_content_reload] +@Composable +fun InitialFocusWithContentReloadScreen( + viewModel: InitialFocusEnablingContentReloadViewModel = viewModel(), + onCardClick: (String) -> Unit = {} +) { + val cardData by viewModel.cardData.collectAsStateWithLifecycle() + val initialFocus = remember { FocusRequester() } + val state = rememberLazyListState() + + // Recreate the flag when the cardData value changes by reloads + var isSafeToSetInitialFocus by remember(cardData) { + mutableStateOf(false) + } + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(horizontal = 32.dp), + state = state, + ) { + itemsIndexed(cardData) { index, item -> + val cardModifier = when (index) { + // Associate the FocusRequester object with the first card + 0 -> + Modifier + .focusRequester(initialFocus) + .onGloballyPositioned { + // Set the flag true if the element in the viewport + isSafeToSetInitialFocus = true + } + + else -> Modifier + } + Card( + onClick = { onCardClick(item) }, + modifier = cardModifier + ) { + Text(item, modifier = Modifier.padding(16.dp)) + } + } + item { + // Click to reload the content + Card(onClick = viewModel::reload) { + Text("Reload", modifier = Modifier.padding(16.dp)) + } + } + } + + // The flag is the key to trigger the coroutine to set initial focus. + LaunchedEffect(isSafeToSetInitialFocus) { + // Scroll to the first item + state.animateScrollToItem(0) + // Your app should set initial focus only if the UI element is in viewport + if (isSafeToSetInitialFocus) { + initialFocus.requestFocus() + } + } +} +// [END android_compose_touchinput_initialfocus_with_content_reload] diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/keyboardinput/KeyboardShortcutsHelper.kt b/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/keyboardinput/KeyboardShortcutsHelper.kt index a7f21362..2b9c4781 100644 --- a/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/keyboardinput/KeyboardShortcutsHelper.kt +++ b/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/keyboardinput/KeyboardShortcutsHelper.kt @@ -27,16 +27,27 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.MutableStateFlow -class MainActivity : ComponentActivity() { - // Activity codes such as overridden onStart method. +// [START android_compose_keyboard_shortcuts_helper] +class KeyboardShortcutsHelperActivity : ComponentActivity() { @RequiresApi(Build.VERSION_CODES.N) - // [START android_compose_keyboard_shortcuts_helper] override fun onProvideKeyboardShortcuts( data: MutableList?, menu: Menu?, @@ -54,10 +65,11 @@ class MainActivity : ComponentActivity() { ) data?.add(shortcutGroup) } - // [END android_compose_keyboard_shortcuts_helper] } +// [END android_compose_keyboard_shortcuts_helper] -class AnotherActivity : ComponentActivity() { + +class KeyboardShortcutsHelperRequestActivity : ComponentActivity() { @RequiresApi(Build.VERSION_CODES.N) override fun onCreate(savedInstanceState: Bundle?) { @@ -114,3 +126,144 @@ class AnotherActivity : ComponentActivity() { } // [END android_compose_keyboard_shortcuts_helper_with_groups] } + +private sealed class CursorMovement(val label: String) { + + data object Up : CursorMovement("Up") + data object Down : CursorMovement("Down") + data object Forward : CursorMovement("Forward") + data object Backward : CursorMovement("Backward") +} + +private class CursorMovementKeyboardShortcut( + val cursorMovement: CursorMovement, + val key: Int, + val modifiers: Int = 0, +) { + @RequiresApi(Build.VERSION_CODES.N) + fun intoKeyboardShortcutInfo(): KeyboardShortcutInfo { + return KeyboardShortcutInfo( + cursorMovement.label, + key, + modifiers + ) + } +} + +private enum class CursorMovementStyle( + val label: String, + val shortcuts: List +) { + + Emacs( + "Emacs", listOf( + CursorMovementKeyboardShortcut( + CursorMovement.Up, + KeyEvent.KEYCODE_P, + KeyEvent.META_CTRL_ON + ), + CursorMovementKeyboardShortcut( + CursorMovement.Down, + KeyEvent.KEYCODE_N, + KeyEvent.META_CTRL_ON + ), + CursorMovementKeyboardShortcut( + CursorMovement.Forward, + KeyEvent.KEYCODE_F, + KeyEvent.META_CTRL_ON + ), + CursorMovementKeyboardShortcut( + CursorMovement.Backward, + KeyEvent.KEYCODE_B, + KeyEvent.META_CTRL_ON + ), + + CursorMovementKeyboardShortcut(CursorMovement.Up, KeyEvent.KEYCODE_DPAD_UP), + CursorMovementKeyboardShortcut(CursorMovement.Down, KeyEvent.KEYCODE_DPAD_DOWN), + CursorMovementKeyboardShortcut(CursorMovement.Forward, KeyEvent.KEYCODE_DPAD_RIGHT), + CursorMovementKeyboardShortcut(CursorMovement.Backward, KeyEvent.KEYCODE_DPAD_LEFT), + ) + ), + Vim( + "Vim", listOf( + CursorMovementKeyboardShortcut(CursorMovement.Up, KeyEvent.KEYCODE_K), + CursorMovementKeyboardShortcut(CursorMovement.Down, KeyEvent.KEYCODE_J), + CursorMovementKeyboardShortcut(CursorMovement.Forward, KeyEvent.KEYCODE_L), + CursorMovementKeyboardShortcut(CursorMovement.Backward, KeyEvent.KEYCODE_H), + + CursorMovementKeyboardShortcut(CursorMovement.Up, KeyEvent.KEYCODE_DPAD_UP), + CursorMovementKeyboardShortcut(CursorMovement.Down, KeyEvent.KEYCODE_DPAD_DOWN), + CursorMovementKeyboardShortcut(CursorMovement.Forward, KeyEvent.KEYCODE_DPAD_RIGHT), + CursorMovementKeyboardShortcut(CursorMovement.Backward, KeyEvent.KEYCODE_DPAD_LEFT), + ) + ); + + @RequiresApi(Build.VERSION_CODES.N) + fun intoKeyboardShortcutGroup(): KeyboardShortcutGroup { + return KeyboardShortcutGroup( + "Cursor movement($label)", + shortcuts.map { it.intoKeyboardShortcutInfo() } + ) + } +} + +class ShortcutCustomizableKeyboardShortcutsHelperActivity : ComponentActivity() { + private var cursorMovement = MutableStateFlow(CursorMovementStyle.Emacs) + + @RequiresApi(Build.VERSION_CODES.N) + override fun onProvideKeyboardShortcuts( + data: MutableList?, + menu: Menu?, + deviceId: Int + ) { + val cursorMovementSection = cursorMovement.value.intoKeyboardShortcutGroup() + data?.add(cursorMovementSection) + } + + private fun updateCursorMovementStyle(style: CursorMovementStyle) { + cursorMovement.value = style + } + + @RequiresApi(Build.VERSION_CODES.N) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + val cursorMovementKeyboardShortcuts by cursorMovement.collectAsStateWithLifecycle() + val activity = LocalContext.current as? Activity + + Box( + modifier = Modifier + .safeDrawingPadding() + .fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column { + SingleChoiceSegmentedButtonRow { + CursorMovementStyle.entries.forEachIndexed { index, cursorMovementStyle -> + SegmentedButton( + selected = cursorMovementStyle == cursorMovementKeyboardShortcuts, + onClick = { updateCursorMovementStyle(cursorMovementStyle) }, + label = { Text(cursorMovementStyle.label) }, + shape = SegmentedButtonDefaults.itemShape( + index = index, + count = CursorMovementStyle.entries.size + ) + ) + } + } + Button( + onClick = { + activity?.requestShowKeyboardShortcuts() + } + ) { + Text(text = "Show keyboard shortcuts") + } + } + } + } + } + } + +} \ No newline at end of file