Skip to content

Commit bdc8892

Browse files
committed
Redesign tab group API
1 parent 085287e commit bdc8892

11 files changed

Lines changed: 324 additions & 266 deletions

File tree

composeunstyled-tab-group/src/commonMain/kotlin/com/composeunstyled/TabGroup.kt

Lines changed: 67 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ package com.composeunstyled
2626
import androidx.compose.foundation.Indication
2727
import androidx.compose.foundation.LocalIndication
2828
import androidx.compose.foundation.focusGroup
29+
import androidx.compose.foundation.focusable
2930
import androidx.compose.foundation.gestures.Orientation
3031
import androidx.compose.foundation.interaction.MutableInteractionSource
3132
import androidx.compose.foundation.layout.Arrangement
@@ -40,13 +41,11 @@ import androidx.compose.foundation.layout.padding
4041
import androidx.compose.foundation.selection.selectable
4142
import androidx.compose.foundation.selection.selectableGroup
4243
import androidx.compose.runtime.Composable
43-
import androidx.compose.runtime.CompositionLocalProvider
4444
import androidx.compose.runtime.SideEffect
4545
import androidx.compose.runtime.getValue
4646
import androidx.compose.runtime.mutableStateOf
4747
import androidx.compose.runtime.remember
4848
import androidx.compose.runtime.setValue
49-
import androidx.compose.runtime.staticCompositionLocalOf
5049
import androidx.compose.ui.Alignment
5150
import androidx.compose.ui.Modifier
5251
import androidx.compose.ui.focus.FocusDirection
@@ -71,28 +70,44 @@ private val NoPadding = PaddingValues(0.dp)
7170
private val KeyEvent.isKeyDown: Boolean
7271
get() = type == KeyEventType.KeyDown
7372

74-
private class TabsRegistry {
75-
var focusedTab: TabKey? by mutableStateOf(null)
76-
var activatedTab: TabKey? by mutableStateOf(null)
73+
internal class TabsRegistry<T>(
74+
val onSelectedTabChange: (T) -> Unit = {},
75+
) {
76+
var focusedTab: T? by mutableStateOf(null)
77+
var activatedTab: T? by mutableStateOf(null)
7778

78-
var tabKeys: List<TabKey> by mutableStateOf(emptyList())
79-
var tabFocusRequesters: Map<TabKey, FocusRequester> by mutableStateOf(emptyMap())
80-
var panelsFocusRequesters: Map<TabKey, FocusRequester> by mutableStateOf(emptyMap())
79+
var tabKeys: List<T> by mutableStateOf(emptyList())
80+
var tabFocusRequesters: Map<T, FocusRequester> by mutableStateOf(emptyMap())
81+
var panelsFocusRequesters: Map<T, FocusRequester> by mutableStateOf(emptyMap())
8182
}
8283

83-
private val LocalTabsRegistry = staticCompositionLocalOf { TabsRegistry() }
84+
class TabGroupScope<T> internal constructor(
85+
internal val registry: TabsRegistry<T>,
86+
columnScope: ColumnScope,
87+
) : ColumnScope by columnScope
88+
89+
class TabListScope<T> internal constructor(
90+
internal val registry: TabsRegistry<T>,
91+
rowScope: RowScope,
92+
) : RowScope by rowScope
93+
94+
class TabScope internal constructor(
95+
val selected: Boolean,
96+
val enabled: Boolean,
97+
)
8498

8599
@Composable
86-
fun UnstyledTabGroup(
87-
selectedTab: TabKey,
88-
tabs: List<TabKey>,
100+
fun <T> UnstyledTabGroup(
101+
selectedTab: T,
102+
onSelectedTabChange: (T) -> Unit,
103+
tabs: List<T>,
89104
modifier: Modifier = Modifier,
90-
content: @Composable ColumnScope.() -> Unit,
105+
content: @Composable TabGroupScope<T>.() -> Unit,
91106
) {
92-
val registry = remember(tabs) {
107+
val registry = remember(tabs, onSelectedTabChange) {
93108
val tabsRequesters = tabs.associateWith { FocusRequester() }
94109
val panelsRequesters = tabs.associateWith { FocusRequester() }
95-
TabsRegistry().apply {
110+
TabsRegistry(onSelectedTabChange = onSelectedTabChange).apply {
96111
tabKeys = tabs
97112
tabFocusRequesters = tabsRequesters
98113
panelsFocusRequesters = panelsRequesters
@@ -116,22 +131,23 @@ fun UnstyledTabGroup(
116131
}
117132
.focusGroup(),
118133
) {
119-
CompositionLocalProvider(LocalTabsRegistry provides registry) {
120-
content()
134+
val tabGroupScope = remember(registry, this) {
135+
TabGroupScope(registry, this)
121136
}
137+
tabGroupScope.content()
122138
}
123139
}
124140

125141
@Composable
126-
fun UnstyledTabList(
142+
fun <T> TabGroupScope<T>.TabList(
127143
modifier: Modifier = Modifier,
128144
contentPadding: PaddingValues = NoPadding,
129145
orientation: Orientation = Orientation.Horizontal,
130146
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
131147
verticalAlignment: Alignment.Vertical = Alignment.Top,
132-
content: @Composable RowScope.() -> Unit,
148+
content: @Composable TabListScope<T>.() -> Unit,
133149
) {
134-
val registry = LocalTabsRegistry.current
150+
val registry = registry
135151
val tabKeys = registry.tabKeys
136152

137153
Row(
@@ -143,7 +159,7 @@ fun UnstyledTabList(
143159
.onKeyEvent { event ->
144160
when {
145161
orientation == Orientation.Horizontal && event.key == Key.DirectionLeft -> {
146-
if (event.isKeyDown) {
162+
if (event.isKeyDown && tabKeys.isNotEmpty()) {
147163
val currentIndex = tabKeys.indexOf(registry.focusedTab)
148164

149165
val nextIndex = (currentIndex - 1 + tabKeys.size) % tabKeys.size
@@ -156,7 +172,7 @@ fun UnstyledTabList(
156172
}
157173

158174
orientation == Orientation.Horizontal && event.key == Key.DirectionRight -> {
159-
if (event.isKeyDown) {
175+
if (event.isKeyDown && tabKeys.isNotEmpty()) {
160176
val currentIndex = tabKeys.indexOf(registry.focusedTab)
161177

162178
val nextIndex = (currentIndex + 1) % tabKeys.size
@@ -169,7 +185,7 @@ fun UnstyledTabList(
169185
}
170186

171187
orientation == Orientation.Vertical && event.key == Key.DirectionUp -> {
172-
if (event.isKeyDown) {
188+
if (event.isKeyDown && tabKeys.isNotEmpty()) {
173189
val currentIndex = tabKeys.indexOf(registry.focusedTab)
174190

175191
val nextIndex = (currentIndex - 1 + tabKeys.size) % tabKeys.size
@@ -182,7 +198,7 @@ fun UnstyledTabList(
182198
}
183199

184200
orientation == Orientation.Vertical && event.key == Key.DirectionDown -> {
185-
if (event.isKeyDown) {
201+
if (event.isKeyDown && tabKeys.isNotEmpty()) {
186202
val currentIndex = tabKeys.indexOf(registry.focusedTab)
187203

188204
val nextIndex = (currentIndex + 1) % tabKeys.size
@@ -195,7 +211,7 @@ fun UnstyledTabList(
195211
}
196212

197213
event.key == Key.Home -> {
198-
if (event.isKeyDown) {
214+
if (event.isKeyDown && tabKeys.isNotEmpty()) {
199215
val tab = tabKeys.first()
200216
val focusRequester = registry.tabFocusRequesters.getValue(tab)
201217
focusRequester.requestFocus()
@@ -204,7 +220,7 @@ fun UnstyledTabList(
204220
}
205221

206222
event.key == Key.MoveEnd -> {
207-
if (event.isKeyDown) {
223+
if (event.isKeyDown && tabKeys.isNotEmpty()) {
208224
val tab = tabKeys.last()
209225
val focusRequester = registry.tabFocusRequesters.getValue(tab)
210226
focusRequester.requestFocus()
@@ -223,27 +239,34 @@ fun UnstyledTabList(
223239
horizontalArrangement = horizontalArrangement,
224240
verticalAlignment = verticalAlignment,
225241
) {
226-
content()
242+
val tabListScope = remember(registry, this) {
243+
TabListScope(registry, this)
244+
}
245+
tabListScope.content()
227246
}
228247
}
229248

230249
@Composable
231-
fun UnstyledTab(
232-
key: TabKey,
233-
selected: Boolean,
234-
onSelected: () -> Unit,
250+
fun <T> TabListScope<T>.Tab(
251+
key: T,
235252
modifier: Modifier = Modifier,
236253
enabled: Boolean = true,
237254
activateOnFocus: Boolean = true,
238-
indication: Indication = LocalIndication.current,
255+
indication: Indication? = LocalIndication.current,
239256
interactionSource: MutableInteractionSource? = null,
240257
contentPadding: PaddingValues = NoPadding,
241-
content: @Composable () -> Unit,
258+
content: @Composable TabScope.() -> Unit,
242259
) {
243-
val registry = LocalTabsRegistry.current
244-
val focusRequester = registry.tabFocusRequesters[key]
245-
?: error("Tried to setup a Tab with key = $key but was not found in the tab keys. Make sure you provide the key")
260+
val registry = registry
261+
val focusRequester = registry.tabFocusRequesters[key] ?: FocusRequester.Default
246262
val activatedTab = registry.activatedTab
263+
val selected = activatedTab == key
264+
val tabScope = remember(selected, enabled) {
265+
TabScope(
266+
selected = selected,
267+
enabled = enabled,
268+
)
269+
}
247270

248271
Box(
249272
modifier = modifier
@@ -255,42 +278,42 @@ fun UnstyledTab(
255278
if (it.isFocused) {
256279
registry.focusedTab = key
257280
if (activateOnFocus) {
258-
onSelected()
281+
registry.onSelectedTabChange(key)
259282
}
260283
}
261284
}
262285
.selectable(
263286
selected = selected,
264-
onClick = onSelected,
287+
onClick = { registry.onSelectedTabChange(key) },
265288
indication = indication,
266289
interactionSource = interactionSource,
267290
enabled = enabled,
268291
role = Role.Tab,
269292
)
270293
.padding(contentPadding),
271294
) {
272-
content()
295+
tabScope.content()
273296
}
274297
}
275298

276299
@Composable
277-
fun UnstyledTabPanel(
278-
key: TabKey,
300+
fun <T> TabGroupScope<T>.TabPanel(
301+
key: T,
279302
modifier: Modifier = Modifier,
280303
contentPadding: PaddingValues = NoPadding,
281304
contentAlignment: Alignment = Alignment.TopStart,
282305
content: @Composable BoxScope.() -> Unit,
283306
) {
284-
val registry = LocalTabsRegistry.current
307+
val registry = registry
285308

286309
if (registry.activatedTab == key) {
287-
val focusRequester = registry.panelsFocusRequesters[key]
288-
?: error("Tried to activate TabPanel with key = $key. Did you forget to pass the key in the list of tabs in your TabGroup?")
310+
val focusRequester = registry.panelsFocusRequesters[key] ?: FocusRequester.Default
289311

290312
Box(
291313
modifier = modifier
292314
.padding(contentPadding)
293315
.focusRequester(focusRequester)
316+
.focusable()
294317
.focusGroup(),
295318
contentAlignment = contentAlignment,
296319
) {

composeunstyled-tab-group/src/commonTest/kotlin/TabGroup.test.kt

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,53 +29,54 @@ import androidx.compose.ui.Modifier
2929
import androidx.compose.ui.platform.testTag
3030
import androidx.compose.ui.test.isDisplayed
3131
import androidx.compose.ui.test.onNodeWithTag
32+
import androidx.compose.ui.test.onNodeWithText
3233
import androidx.compose.ui.test.performClick
3334
import androidx.compose.ui.test.runComposeUiTest
3435
import kotlin.test.Test
3536

3637
class TabGroupCommonTest {
38+
private enum class SettingsTab {
39+
Account,
40+
Billing,
41+
}
42+
3743
@Test
3844
fun showsPanelOfTheActivatedTab() = runComposeUiTest {
3945
var selectedTab by mutableStateOf("tab1")
4046

4147
setContent {
4248
UnstyledTabGroup(
4349
selectedTab = selectedTab,
50+
onSelectedTabChange = { selectedTab = it },
4451
tabs = listOf("tab1", "tab2", "tab3"),
4552
) {
46-
UnstyledTabList(Modifier.testTag("tablist")) {
47-
UnstyledTab(
53+
TabList(Modifier.testTag("tablist")) {
54+
Tab(
4855
key = "tab1",
4956
modifier = Modifier.testTag("tab1"),
50-
selected = selectedTab == "tab1",
51-
onSelected = { selectedTab = "tab1" },
5257
) {
5358
BasicText("Tab #1")
5459
}
55-
UnstyledTab(
60+
Tab(
5661
key = "tab2",
5762
modifier = Modifier.testTag("tab2"),
58-
selected = selectedTab == "tab2",
59-
onSelected = { selectedTab = "tab2" },
6063
) {
6164
BasicText("Tab #2")
6265
}
63-
UnstyledTab(
66+
Tab(
6467
key = "tab3",
6568
modifier = Modifier.testTag("tab3"),
66-
selected = selectedTab == "tab3",
67-
onSelected = { selectedTab = "tab3" },
6869
) {
6970
BasicText("Tab #3")
7071
}
7172
}
72-
UnstyledTabPanel(key = "tab1", Modifier.testTag("panelA")) {
73+
TabPanel(key = "tab1", Modifier.testTag("panelA")) {
7374
BasicText("Panel A")
7475
}
75-
UnstyledTabPanel(key = "tab2", Modifier.testTag("panelB")) {
76+
TabPanel(key = "tab2", Modifier.testTag("panelB")) {
7677
BasicText("Panel B")
7778
}
78-
UnstyledTabPanel(key = "tab3", Modifier.testTag("panelC")) {
79+
TabPanel(key = "tab3", Modifier.testTag("panelC")) {
7980
BasicText("Panel C")
8081
}
8182
}
@@ -96,4 +97,43 @@ class TabGroupCommonTest {
9697
onNodeWithTag("panelB").assertDoesNotExist()
9798
onNodeWithTag("panelC").isDisplayed()
9899
}
100+
101+
@Test
102+
fun supportsTypedTabKeys() = runComposeUiTest {
103+
var selectedTab by mutableStateOf(SettingsTab.Account)
104+
105+
setContent {
106+
UnstyledTabGroup(
107+
selectedTab = selectedTab,
108+
onSelectedTabChange = { selectedTab = it },
109+
tabs = SettingsTab.entries,
110+
) {
111+
TabList {
112+
Tab(
113+
key = SettingsTab.Account,
114+
modifier = Modifier.testTag("account"),
115+
) {
116+
BasicText("Account")
117+
}
118+
Tab(
119+
key = SettingsTab.Billing,
120+
modifier = Modifier.testTag("billing"),
121+
) {
122+
BasicText("Billing")
123+
}
124+
}
125+
TabPanel(key = SettingsTab.Account) {
126+
BasicText("Account panel")
127+
}
128+
TabPanel(key = SettingsTab.Billing) {
129+
BasicText("Billing panel")
130+
}
131+
}
132+
}
133+
134+
onNodeWithTag("billing").performClick()
135+
136+
onNodeWithText("Account panel").assertDoesNotExist()
137+
onNodeWithText("Billing panel").isDisplayed()
138+
}
99139
}

0 commit comments

Comments
 (0)