From 61e2f387491d2bbb5bfa45fe0fd7594e7ce7ec77 Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Mon, 18 Nov 2024 16:30:55 +0900 Subject: [PATCH 1/6] Add snippets for setting initial focus --- .../compose/snippets/SnippetsActivity.kt | 2 + .../snippets/navigation/Destination.kt | 5 +- .../snippets/touchinput/focus/FocusExample.kt | 103 +++++++++ .../touchinput/focus/InitialFocusSnippets.kt | 216 ++++++++++++++++++ 4 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/FocusExample.kt create mode 100644 compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/InitialFocusSnippets.kt diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/SnippetsActivity.kt b/compose/snippets/src/main/java/com/example/compose/snippets/SnippetsActivity.kt index f07c46133..7e5843c16 100644 --- a/compose/snippets/src/main/java/com/example/compose/snippets/SnippetsActivity.kt +++ b/compose/snippets/src/main/java/com/example/compose/snippets/SnippetsActivity.kt @@ -59,6 +59,7 @@ import com.example.compose.snippets.landing.LandingScreen import com.example.compose.snippets.layouts.PagerExamples import com.example.compose.snippets.navigation.Destination import com.example.compose.snippets.navigation.TopComponentsDestination +import com.example.compose.snippets.touchinput.focus.FocusExample import com.example.compose.snippets.ui.theme.SnippetsTheme class SnippetsActivity : ComponentActivity() { @@ -92,6 +93,7 @@ class SnippetsActivity : ComponentActivity() { Destination.ShapesExamples -> 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 783963906..3c1639472 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 000000000..e9bf511a4 --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/FocusExample.kt @@ -0,0 +1,103 @@ +/* + * 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.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.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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"), + FocusTraversal("focusTraversal", "Focus Traversal"), + InitialFocus("initialFocus", "Initial Focus"), + InitialFocusWithScrollableContainer( + "initialFocusWithScrollableContainer", + "Initial Focus with Scrollable Container" + ), + InitialFocusEnablingContentReload( + "initialFocusEnablingContentReload", + "Initial Focus Enabling Content Reload" + ) +} + +@Composable +fun FocusExample( + navController: NavHostController = rememberNavController() +) { + NavHost(navController, startDestination = FocusExample.Home.route) { + composable(FocusExample.Home.route) { + val entries = remember { + listOf( + FocusExample.InitialFocus, + FocusExample.InitialFocusWithScrollableContainer, + FocusExample.InitialFocusEnablingContentReload + ) + } + FocusExampleScreen(entries) { + navController.navigate(it.route) + } + } + composable(FocusExample.FocusTraversal.route) { + } + composable(FocusExample.InitialFocus.route) { + InitialFocusScreen() + } + composable(FocusExample.InitialFocusWithScrollableContainer.route) { + InitialFocusWithScrollableContainerScreen() + } + composable(FocusExample.InitialFocusEnablingContentReload.route) { + InitialFocusWithContentReloadScreen() + } + } +} + +@Composable +private fun FocusExampleScreen( + examples: List, + onExampleClick: (FocusExample) -> Unit = {} +) { + Box(contentAlignment = Alignment.TopCenter) { + LazyColumn( + modifier = Modifier.widthIn( + max = 600.dp + ) + ) { + items(examples) { + ListItem( + headlineContent = { Text(it.title) }, + modifier = Modifier.clickable { onExampleClick(it) } + ) + } + } + } +} 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 000000000..6934d9421 --- /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] From 2bc846103aebaaee796acd7624e075af0ed5fa46 Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Wed, 4 Dec 2024 16:39:40 +0900 Subject: [PATCH 2/6] Add a snippet for focus restoration in list-detail layout implemented wth ListDetailPaneScaffold --- ...cusRestorationInListDetailLayoutSnippet.kt | 238 ++++++++++++++++++ .../focus/FocusRestorationSnippets.kt | 17 ++ 2 files changed, 255 insertions(+) create mode 100644 compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/FocusRestorationInListDetailLayoutSnippet.kt create mode 100644 compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/FocusRestorationSnippets.kt 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 000000000..6a8e1500a --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/FocusRestorationInListDetailLayoutSnippet.kt @@ -0,0 +1,238 @@ +/* + * 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 } +} \ No newline at end of file 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 000000000..cea12248e --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/FocusRestorationSnippets.kt @@ -0,0 +1,17 @@ +/* + * 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 From 07f84a6c23bf96e7eb1df399aeae956490b9c45a Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Thu, 5 Dec 2024 14:51:18 +0900 Subject: [PATCH 3/6] Add snippets for focus restoration --- .../snippets/touchinput/focus/FocusExample.kt | 70 ++++- .../focus/FocusRestorationSnippets.kt | 269 ++++++++++++++++++ 2 files changed, 326 insertions(+), 13 deletions(-) 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 index e9bf511a4..495b1976f 100644 --- 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 @@ -24,9 +24,14 @@ 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.unit.dp import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost @@ -47,7 +52,9 @@ enum class FocusExample( InitialFocusEnablingContentReload( "initialFocusEnablingContentReload", "Initial Focus Enabling Content Reload" - ) + ), + FocusRestoration("focusRestoration", "Focus Restoration"), + FocusInListDetailLayout("focusInListDetailLayout", "Focus In List-Detail Layout"), } @Composable @@ -60,7 +67,9 @@ fun FocusExample( listOf( FocusExample.InitialFocus, FocusExample.InitialFocusWithScrollableContainer, - FocusExample.InitialFocusEnablingContentReload + FocusExample.InitialFocusEnablingContentReload, + FocusExample.FocusRestoration, + FocusExample.FocusInListDetailLayout, ) } FocusExampleScreen(entries) { @@ -78,26 +87,61 @@ fun FocusExample( composable(FocusExample.InitialFocusEnablingContentReload.route) { InitialFocusWithContentReloadScreen() } + composable(FocusExample.FocusRestoration.route) { + FocusRestorationScreen() + } + composable(FocusExample.FocusInListDetailLayout.route) { + FocusRestorationInListDetailScreen() + } } } +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun FocusExampleScreen( examples: List, - onExampleClick: (FocusExample) -> Unit = {} + navigateToDetails: (FocusExample) -> Unit, ) { + val focusRequester = remember { FocusRequester() } + Box(contentAlignment = Alignment.TopCenter) { - LazyColumn( - modifier = Modifier.widthIn( - max = 600.dp + 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, +) { + // [START android_compose_touchinput_focus_restoration] + LazyColumn( + modifier = modifier.focusRestorer() + ) { + items(examples) { + ListItem( + headlineContent = { Text(it.title) }, + modifier = Modifier.clickable { + showDetails(it) + } ) - ) { - items(examples) { - ListItem( - headlineContent = { Text(it.title) }, - modifier = Modifier.clickable { onExampleClick(it) } - ) - } } } + // [END android_compose_touchinput_focus_restoration] } 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 index cea12248e..0548de8cd 100644 --- 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 @@ -15,3 +15,272 @@ */ 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 + ) + } +} From 018fb008f869b87fee64beb06719c0e3d310d9bf Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Thu, 5 Dec 2024 16:23:51 +0900 Subject: [PATCH 4/6] spotlessApply --- .../focus/FocusRestorationInListDetailLayoutSnippet.kt | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) 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 index 6a8e1500a..3e6d88394 100644 --- 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 @@ -136,7 +136,7 @@ fun FocusRestorationInListDetail(catalogData: List, modifier: Modif detailPane = { AnimatedPane { val catalogItem = threePaneScaffoldNavigator.currentDestination?.content - if(catalogItem !=null){ + if (catalogItem != null) { DetailsPane(catalogItem) } } @@ -147,7 +147,7 @@ fun FocusRestorationInListDetail(catalogData: List, modifier: Modif LaunchedEffect(isListPaneVisible) { if (isListPaneVisible) { val catalogItemIndex = catalogData.indexOf(lastSelectedCatalogItem) - if(catalogItemIndex >= 0) { + if (catalogItemIndex >= 0) { // Ensure the ListItem for the last selected item is visible listState.animateScrollToItem(catalogItemIndex) } @@ -157,7 +157,6 @@ fun FocusRestorationInListDetail(catalogData: List, modifier: Modif } // [END android_compose_touchinput_focus_restoration_listdetail] - @OptIn(ExperimentalComposeUiApi::class) @Composable private fun ListPane( @@ -213,7 +212,6 @@ private fun DetailsPane( Text("Click me") } } - } fun FocusRequester.tryRequestFocus(): Result { @@ -227,7 +225,7 @@ fun FocusRequester.tryRequestFocus(): Result { @Composable fun Modifier.initialFocus(focusRequester: FocusRequester = remember { FocusRequester() }): Modifier { - var isSafe by remember{ mutableStateOf(false) } + var isSafe by remember { mutableStateOf(false) } LaunchedEffect(isSafe) { if (isSafe) { @@ -235,4 +233,4 @@ fun Modifier.initialFocus(focusRequester: FocusRequester = remember { FocusReque } } return this.focusRequester(focusRequester).onPlaced { isSafe = true } -} \ No newline at end of file +} From 3d3e77d0f8d4dc0e5b96100ed40db90ee7863427 Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Thu, 9 Jan 2025 17:47:02 +0900 Subject: [PATCH 5/6] Add FocusTraversalOrderScreen to try focus traversal order --- .../snippets/touchinput/focus/FocusExample.kt | 12 +- .../touchinput/focus/FocusSnippets.kt | 310 +++++++++++++++++- 2 files changed, 307 insertions(+), 15 deletions(-) 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 index 495b1976f..b9a5e4dbd 100644 --- 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 @@ -18,6 +18,7 @@ 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 @@ -32,6 +33,7 @@ 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 @@ -43,7 +45,7 @@ enum class FocusExample( val title: String ) { Home("home", "Home"), - FocusTraversal("focusTraversal", "Focus Traversal"), + FocusTraversalOrder("focusTraversal", "Focus Traversal Order"), InitialFocus("initialFocus", "Initial Focus"), InitialFocusWithScrollableContainer( "initialFocusWithScrollableContainer", @@ -65,6 +67,7 @@ fun FocusExample( composable(FocusExample.Home.route) { val entries = remember { listOf( + FocusExample.FocusTraversalOrder, FocusExample.InitialFocus, FocusExample.InitialFocusWithScrollableContainer, FocusExample.InitialFocusEnablingContentReload, @@ -76,7 +79,8 @@ fun FocusExample( navController.navigate(it.route) } } - composable(FocusExample.FocusTraversal.route) { + composable(FocusExample.FocusTraversalOrder.route) { + FocusTraversalScreen(modifier = Modifier.padding(16.dp)) } composable(FocusExample.InitialFocus.route) { InitialFocusScreen() @@ -129,7 +133,9 @@ 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() 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 82b5b2d1e..341ffa9e0 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) From 88ba91c2ec74bf59eb03a78d9eff40841ceb1fe2 Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Thu, 9 Jan 2025 17:52:17 +0900 Subject: [PATCH 6/6] Add a sample code to update KeyboardShortcutGroup dynamically --- compose/snippets/src/main/AndroidManifest.xml | 3 + .../keyboardinput/KeyboardShortcutsHelper.kt | 163 +++++++++++++++++- 2 files changed, 161 insertions(+), 5 deletions(-) diff --git a/compose/snippets/src/main/AndroidManifest.xml b/compose/snippets/src/main/AndroidManifest.xml index c9b15e6c5..30e91caf8 100644 --- a/compose/snippets/src/main/AndroidManifest.xml +++ b/compose/snippets/src/main/AndroidManifest.xml @@ -55,6 +55,9 @@ android:exported="false"/> + + + ?, 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