Skip to content

Commit 07f84a6

Browse files
committed
Add snippets for focus restoration
1 parent 2bc8461 commit 07f84a6

File tree

2 files changed

+326
-13
lines changed

2 files changed

+326
-13
lines changed

compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/FocusExample.kt

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,14 @@ import androidx.compose.foundation.lazy.items
2424
import androidx.compose.material3.ListItem
2525
import androidx.compose.material3.Text
2626
import androidx.compose.runtime.Composable
27+
import androidx.compose.runtime.LaunchedEffect
2728
import androidx.compose.runtime.remember
2829
import androidx.compose.ui.Alignment
30+
import androidx.compose.ui.ExperimentalComposeUiApi
2931
import androidx.compose.ui.Modifier
32+
import androidx.compose.ui.focus.FocusRequester
33+
import androidx.compose.ui.focus.focusRequester
34+
import androidx.compose.ui.focus.focusRestorer
3035
import androidx.compose.ui.unit.dp
3136
import androidx.navigation.NavHostController
3237
import androidx.navigation.compose.NavHost
@@ -47,7 +52,9 @@ enum class FocusExample(
4752
InitialFocusEnablingContentReload(
4853
"initialFocusEnablingContentReload",
4954
"Initial Focus Enabling Content Reload"
50-
)
55+
),
56+
FocusRestoration("focusRestoration", "Focus Restoration"),
57+
FocusInListDetailLayout("focusInListDetailLayout", "Focus In List-Detail Layout"),
5158
}
5259

5360
@Composable
@@ -60,7 +67,9 @@ fun FocusExample(
6067
listOf(
6168
FocusExample.InitialFocus,
6269
FocusExample.InitialFocusWithScrollableContainer,
63-
FocusExample.InitialFocusEnablingContentReload
70+
FocusExample.InitialFocusEnablingContentReload,
71+
FocusExample.FocusRestoration,
72+
FocusExample.FocusInListDetailLayout,
6473
)
6574
}
6675
FocusExampleScreen(entries) {
@@ -78,26 +87,61 @@ fun FocusExample(
7887
composable(FocusExample.InitialFocusEnablingContentReload.route) {
7988
InitialFocusWithContentReloadScreen()
8089
}
90+
composable(FocusExample.FocusRestoration.route) {
91+
FocusRestorationScreen()
92+
}
93+
composable(FocusExample.FocusInListDetailLayout.route) {
94+
FocusRestorationInListDetailScreen()
95+
}
8196
}
8297
}
8398

99+
@OptIn(ExperimentalComposeUiApi::class)
84100
@Composable
85101
private fun FocusExampleScreen(
86102
examples: List<FocusExample>,
87-
onExampleClick: (FocusExample) -> Unit = {}
103+
navigateToDetails: (FocusExample) -> Unit,
88104
) {
105+
val focusRequester = remember { FocusRequester() }
106+
89107
Box(contentAlignment = Alignment.TopCenter) {
90-
LazyColumn(
91-
modifier = Modifier.widthIn(
92-
max = 600.dp
108+
ExampleList(
109+
examples = examples,
110+
showDetails = {
111+
// Save the focused child before navigating to the detail pane
112+
focusRequester.saveFocusedChild()
113+
navigateToDetails(it)
114+
},
115+
modifier = Modifier
116+
.widthIn(max = 600.dp)
117+
.focusRequester(focusRequester)
118+
)
119+
}
120+
121+
LaunchedEffect(Unit) {
122+
focusRequester.requestFocus()
123+
}
124+
}
125+
126+
@OptIn(ExperimentalComposeUiApi::class)
127+
@Composable
128+
private fun ExampleList(
129+
examples: List<FocusExample>,
130+
showDetails: (FocusExample) -> Unit = {},
131+
modifier: Modifier = Modifier,
132+
) {
133+
// [START android_compose_touchinput_focus_restoration]
134+
LazyColumn(
135+
modifier = modifier.focusRestorer()
136+
) {
137+
items(examples) {
138+
ListItem(
139+
headlineContent = { Text(it.title) },
140+
modifier = Modifier.clickable {
141+
showDetails(it)
142+
}
93143
)
94-
) {
95-
items(examples) {
96-
ListItem(
97-
headlineContent = { Text(it.title) },
98-
modifier = Modifier.clickable { onExampleClick(it) }
99-
)
100-
}
101144
}
102145
}
146+
// [END android_compose_touchinput_focus_restoration]
103147
}

compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/FocusRestorationSnippets.kt

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,272 @@
1515
*/
1616

1717
package com.example.compose.snippets.touchinput.focus
18+
19+
import androidx.compose.foundation.ExperimentalFoundationApi
20+
import androidx.compose.foundation.focusGroup
21+
import androidx.compose.foundation.layout.Arrangement
22+
import androidx.compose.foundation.layout.Box
23+
import androidx.compose.foundation.layout.BoxScope
24+
import androidx.compose.foundation.layout.Column
25+
import androidx.compose.foundation.layout.PaddingValues
26+
import androidx.compose.foundation.layout.Row
27+
import androidx.compose.foundation.layout.aspectRatio
28+
import androidx.compose.foundation.layout.fillMaxSize
29+
import androidx.compose.foundation.layout.padding
30+
import androidx.compose.foundation.layout.width
31+
import androidx.compose.foundation.lazy.LazyColumn
32+
import androidx.compose.foundation.lazy.LazyListState
33+
import androidx.compose.foundation.lazy.LazyRow
34+
import androidx.compose.foundation.lazy.items
35+
import androidx.compose.foundation.lazy.rememberLazyListState
36+
import androidx.compose.foundation.relocation.BringIntoViewRequester
37+
import androidx.compose.foundation.relocation.bringIntoViewRequester
38+
import androidx.compose.material3.Card
39+
import androidx.compose.material3.MaterialTheme
40+
import androidx.compose.material3.Text
41+
import androidx.compose.runtime.Composable
42+
import androidx.compose.runtime.getValue
43+
import androidx.compose.runtime.remember
44+
import androidx.compose.runtime.rememberCoroutineScope
45+
import androidx.compose.ui.Alignment
46+
import androidx.compose.ui.ExperimentalComposeUiApi
47+
import androidx.compose.ui.Modifier
48+
import androidx.compose.ui.focus.FocusRequester
49+
import androidx.compose.ui.focus.focusProperties
50+
import androidx.compose.ui.focus.focusRequester
51+
import androidx.compose.ui.focus.focusRestorer
52+
import androidx.compose.ui.focus.onFocusChanged
53+
import androidx.compose.ui.unit.Dp
54+
import androidx.compose.ui.unit.dp
55+
import androidx.lifecycle.ViewModel
56+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
57+
import androidx.lifecycle.viewModelScope
58+
import androidx.lifecycle.viewmodel.compose.viewModel
59+
import kotlinx.coroutines.CoroutineScope
60+
import kotlinx.coroutines.flow.MutableStateFlow
61+
import kotlinx.coroutines.flow.SharingStarted
62+
import kotlinx.coroutines.flow.map
63+
import kotlinx.coroutines.flow.stateIn
64+
import kotlinx.coroutines.launch
65+
66+
data class Section(val name: String, val catalog: List<CatalogItem>)
67+
68+
class FocusRestorationScreenViewModel : ViewModel() {
69+
70+
companion object {
71+
private val sectionListA = listOf(
72+
Section("Section A", CatalogItem.createCatalog(16)),
73+
Section("Section B", CatalogItem.createCatalog(8, startValue = 18)),
74+
Section("Section C", CatalogItem.createCatalog(16, startValue = 27)),
75+
)
76+
77+
private val sectionListB = listOf(
78+
Section("Section D", CatalogItem.createCatalog(8, startValue = 100)),
79+
Section("Section F", CatalogItem.createCatalog(16, startValue = 109)),
80+
Section("Section E", CatalogItem.createCatalog(8, startValue = 126)),
81+
)
82+
83+
private val sectionSet = listOf(sectionListA, sectionListB)
84+
}
85+
86+
private val currentSet = MutableStateFlow(0)
87+
88+
val sections = currentSet.map {
89+
sectionSet[it]
90+
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
91+
92+
fun nextPage() {
93+
currentSet.value = (currentSet.value + 1) % sectionSet.size
94+
}
95+
}
96+
97+
@Composable
98+
fun FocusRestorationScreen(
99+
modifier: Modifier = Modifier,
100+
coroutineScope: CoroutineScope = rememberCoroutineScope(),
101+
focusRestorationScreenViewModel: FocusRestorationScreenViewModel = viewModel()
102+
) {
103+
val sections by focusRestorationScreenViewModel.sections.collectAsStateWithLifecycle()
104+
val state = rememberLazyListState()
105+
val focusRequester = remember { FocusRequester() }
106+
val scrollToTop = remember {
107+
{
108+
coroutineScope.launch {
109+
state.scrollToItem(0)
110+
focusRequester.requestFocus()
111+
}
112+
}
113+
}
114+
115+
CatalogWithSection(
116+
sections = sections,
117+
state = state,
118+
reload = {
119+
focusRestorationScreenViewModel.nextPage()
120+
scrollToTop()
121+
},
122+
scrollToTop = { scrollToTop() },
123+
modifier = modifier.focusRequester(focusRequester)
124+
)
125+
}
126+
127+
@Composable
128+
private fun CatalogWithSection(
129+
sections: List<Section>,
130+
reload: () -> Unit,
131+
scrollToTop: () -> Unit,
132+
modifier: Modifier = Modifier,
133+
state: LazyListState = rememberLazyListState(),
134+
) {
135+
LazyColumn(
136+
verticalArrangement = Arrangement.spacedBy(32.dp),
137+
state = state,
138+
modifier = modifier
139+
) {
140+
items(sections) {
141+
CatalogSection(it)
142+
}
143+
item {
144+
Controls(
145+
reload = reload,
146+
scrollToTop = scrollToTop,
147+
modifier = Modifier.padding(horizontal = 16.dp)
148+
)
149+
}
150+
}
151+
}
152+
153+
@OptIn(ExperimentalFoundationApi::class)
154+
@Composable
155+
private fun CatalogSection(
156+
section: Section,
157+
modifier: Modifier = Modifier,
158+
horizontalOffset: Dp = 16.dp,
159+
coroutineScope: CoroutineScope = rememberCoroutineScope()
160+
) {
161+
val bringIntoViewRequester = remember { BringIntoViewRequester() }
162+
163+
Column(
164+
verticalArrangement = Arrangement.spacedBy(16.dp),
165+
modifier = modifier
166+
.bringIntoViewRequester(bringIntoViewRequester)
167+
.onFocusChanged { focusState ->
168+
// Bring the Column into view port when any CatalogItemCard get focused,
169+
// so that users can see the section title and cards at the same time.
170+
if (focusState.hasFocus) {
171+
coroutineScope.launch {
172+
bringIntoViewRequester.bringIntoView()
173+
}
174+
}
175+
},
176+
) {
177+
Text(
178+
section.name,
179+
style = MaterialTheme.typography.titleLarge,
180+
modifier = Modifier.padding(horizontal = horizontalOffset)
181+
)
182+
SectionCatalog(section.catalog)
183+
}
184+
}
185+
186+
@OptIn(ExperimentalComposeUiApi::class)
187+
@Composable
188+
private fun SectionCatalog(
189+
catalog: List<CatalogItem>,
190+
modifier: Modifier = Modifier,
191+
horizontalOffset: Dp = 16.dp,
192+
) {
193+
// [START android_compose_touchinput_focus_restoration_manually]
194+
val focusRequester = remember(catalog) { FocusRequester() }
195+
196+
LazyRow(
197+
horizontalArrangement = Arrangement.spacedBy(8.dp),
198+
contentPadding = PaddingValues(horizontal = horizontalOffset),
199+
modifier = modifier
200+
.focusRequester(focusRequester)
201+
.focusProperties {
202+
exit = {
203+
focusRequester.saveFocusedChild()
204+
FocusRequester.Default
205+
}
206+
enter = {
207+
if (focusRequester.restoreFocusedChild()) {
208+
FocusRequester.Cancel
209+
} else {
210+
FocusRequester.Default
211+
}
212+
}
213+
}
214+
) {
215+
items(catalog) {
216+
CatalogItemCard(it, modifier = Modifier.width(128.dp))
217+
}
218+
}
219+
// [END android_compose_touchinput_focus_restoration_manually]
220+
}
221+
222+
@Composable
223+
private fun CatalogItemCard(
224+
catalogItem: CatalogItem,
225+
modifier: Modifier = Modifier,
226+
onClick: () -> Unit = {}
227+
) {
228+
Card(onClick = onClick, modifier = modifier.aspectRatio(9f / 16f)) {
229+
Text("${catalogItem.value}", modifier = Modifier.padding(16.dp))
230+
}
231+
}
232+
233+
@OptIn(ExperimentalComposeUiApi::class)
234+
@Composable
235+
private fun Controls(
236+
reload: () -> Unit,
237+
scrollToTop: () -> Unit,
238+
modifier: Modifier = Modifier,
239+
) {
240+
// [START android_compose_touchinput_focus_restoration_with_row]
241+
Row(
242+
horizontalArrangement = Arrangement.spacedBy(8.dp),
243+
modifier = modifier
244+
.focusRestorer()
245+
.focusGroup()
246+
) {
247+
BackToTopCard(onClick = scrollToTop, modifier = Modifier.width(128.dp))
248+
ReloadCard(onClick = reload, modifier = Modifier.width(128.dp))
249+
}
250+
// [END android_compose_touchinput_focus_restoration_with_row]
251+
}
252+
253+
@Composable
254+
private fun ReloadCard(
255+
onClick: () -> Unit = {},
256+
modifier: Modifier = Modifier
257+
) {
258+
SquareCard(modifier = modifier, onClick = onClick) {
259+
Text("Reload")
260+
}
261+
}
262+
263+
@Composable
264+
private fun BackToTopCard(
265+
onClick: () -> Unit = {},
266+
modifier: Modifier = Modifier
267+
) {
268+
SquareCard(modifier = modifier, onClick = onClick) {
269+
Text("To top")
270+
}
271+
}
272+
273+
@Composable
274+
private fun SquareCard(
275+
onClick: () -> Unit = {},
276+
modifier: Modifier = Modifier,
277+
content: @Composable BoxScope.() -> Unit
278+
) {
279+
Card(onClick = onClick, modifier = modifier.aspectRatio(1f)) {
280+
Box(
281+
modifier = Modifier.fillMaxSize(),
282+
contentAlignment = Alignment.Center,
283+
content = content
284+
)
285+
}
286+
}

0 commit comments

Comments
 (0)