Skip to content

Commit bcd9c79

Browse files
Skeptickxxfast
andauthored
ChildItems support (#108)
* Implement `Router` for `ItemsNavigation` * Example for `ItemsNavigation` * Fix state lazy initialisation * Add child items tests * Update api --------- Co-authored-by: xxfast <isurukusumal36@gmail.com>
1 parent fdad05a commit bcd9c79

12 files changed

Lines changed: 464 additions & 1 deletion

File tree

‎app/src/androidDeviceTest/kotlin/io/github/xxfast/decompose/router/app/TestElements.kt‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ package io.github.xxfast.decompose.router.app
33
import androidx.compose.ui.test.hasTestTag
44
import io.github.xxfast.decompose.router.screens.BUTTON_BACK
55
import io.github.xxfast.decompose.router.screens.BOTTOM_NAV_BAR
6+
import io.github.xxfast.decompose.router.screens.BOTTOM_NAV_ITEMS
67
import io.github.xxfast.decompose.router.screens.BOTTOM_NAV_PAGES
78
import io.github.xxfast.decompose.router.screens.BOTTOM_NAV_SLOT
89
import io.github.xxfast.decompose.router.screens.BOTTOM_NAV_STACK
10+
import io.github.xxfast.decompose.router.screens.REMOVE_ITEM
911
import io.github.xxfast.decompose.router.screens.BOTTOM_SHEET
1012
import io.github.xxfast.decompose.router.screens.BUTTON_BOTTOM_SHEET
1113
import io.github.xxfast.decompose.router.screens.BUTTON_DIALOG
@@ -23,6 +25,7 @@ internal val bottomNav = hasTestTag(BOTTOM_NAV_BAR)
2325
internal val bottomNavPagesItem = hasTestTag(BOTTOM_NAV_PAGES)
2426
internal val bottomNavSlotItem = hasTestTag(BOTTOM_NAV_SLOT)
2527
internal val bottomNavStackItem = hasTestTag(BOTTOM_NAV_STACK)
28+
internal val bottomNavItemsItem = hasTestTag(BOTTOM_NAV_ITEMS)
2629
internal val bottomSheet = hasTestTag(BOTTOM_SHEET)
2730
internal val buttonBottomSheet = hasTestTag(BUTTON_BOTTOM_SHEET)
2831
internal val buttonDialog = hasTestTag(BUTTON_DIALOG)
@@ -32,3 +35,5 @@ internal val fabAdd = hasTestTag(FAB_ADD)
3235
internal val lazyColumn = hasTestTag(LIST_TAG)
3336
internal val pager = hasTestTag(PAGER)
3437
internal val titleBar = hasTestTag(TITLE_BAR)
38+
39+
internal fun removeItem(index: Int) = hasTestTag("$REMOVE_ITEM$index")
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package io.github.xxfast.decompose.router.app
2+
3+
import android.content.pm.ActivityInfo
4+
import androidx.compose.ui.test.ExperimentalTestApi
5+
import androidx.compose.ui.test.assertTextEquals
6+
import androidx.compose.ui.test.hasText
7+
import androidx.compose.ui.test.junit4.createAndroidComposeRule
8+
import androidx.compose.ui.test.performClick
9+
import androidx.compose.ui.test.performScrollToNode
10+
import androidx.compose.ui.test.waitUntilDoesNotExist
11+
import androidx.compose.ui.test.waitUntilExactlyOneExists
12+
import org.junit.Rule
13+
import org.junit.Test
14+
15+
class TestItemsRouters {
16+
@get:Rule
17+
val composeRule: TestActivityRule = createAndroidComposeRule()
18+
19+
// Each item renders as "#<index> - <tick>", where <tick> increments every second.
20+
// Matching on the "#<index> - " prefix keeps assertions stable against the ticking
21+
// counter and avoids "#1" also matching "#10"/"#11".
22+
private fun itemAt(index: Int) = hasText("#$index - ", substring = true)
23+
24+
@Test
25+
fun testInitialState(): Unit = with(composeRule) {
26+
// Go to the items screen
27+
onNode(bottomNavItemsItem).assertExists()
28+
onNode(bottomNavItemsItem).performClick()
29+
onNode(titleBar).assertExists().assertTextEquals("Items")
30+
onNode(lazyColumn).assertExists()
31+
32+
// The initial 10 items are present
33+
onNode(itemAt(1)).assertExists()
34+
onNode(lazyColumn).performScrollToNode(itemAt(10))
35+
onNode(itemAt(10)).assertExists()
36+
}
37+
38+
@OptIn(ExperimentalTestApi::class)
39+
@Test
40+
fun testAddAndRemoveItems(): Unit = with(composeRule) {
41+
onNode(bottomNavItemsItem).performClick()
42+
43+
// Add a new item via the FAB and verify it shows up
44+
onNode(fabAdd).performClick()
45+
onNode(lazyColumn).performScrollToNode(itemAt(11))
46+
waitUntilExactlyOneExists(itemAt(11))
47+
48+
// Remove an item and verify it's gone. Scroll to the top first so the removed
49+
// item's button isn't under the floating action button, and wait past the item
50+
// removal animation (each row runs a ticking coroutine, so the clock never idles).
51+
onNode(lazyColumn).performScrollToNode(itemAt(1))
52+
onNode(removeItem(1)).performClick()
53+
waitUntilDoesNotExist(itemAt(1), timeoutMillis = 5_000)
54+
55+
// Removing an item leaves the rest of the list intact
56+
onNode(itemAt(2)).assertExists()
57+
}
58+
59+
@OptIn(ExperimentalTestApi::class)
60+
@Test
61+
fun testRestoredAcrossConfigurationChanges(): Unit = with(composeRule) {
62+
onNode(bottomNavItemsItem).performClick()
63+
onNode(lazyColumn).performScrollToNode(itemAt(3))
64+
onNode(itemAt(3)).assertExists()
65+
66+
// Rotate to landscape and verify the items list is retained. The activity is
67+
// recreated asynchronously, so wait for the rebuilt list before asserting.
68+
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
69+
waitUntilExactlyOneExists(lazyColumn, timeoutMillis = 5_000)
70+
onNode(lazyColumn).performScrollToNode(itemAt(3))
71+
waitUntilExactlyOneExists(itemAt(3), timeoutMillis = 5_000)
72+
73+
// Rotate back and verify it's still there
74+
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
75+
waitUntilExactlyOneExists(lazyColumn, timeoutMillis = 5_000)
76+
onNode(lazyColumn).performScrollToNode(itemAt(3))
77+
waitUntilExactlyOneExists(itemAt(3), timeoutMillis = 5_000)
78+
}
79+
}

‎app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/HomeScreen.kt‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
44
import androidx.compose.foundation.layout.padding
55
import androidx.compose.foundation.pager.HorizontalPager
66
import androidx.compose.material.icons.Icons
7+
import androidx.compose.material.icons.automirrored.rounded.List
78
import androidx.compose.material.icons.rounded.CropSquare
89
import androidx.compose.material.icons.rounded.ImportContacts
910
import androidx.compose.material.icons.rounded.Reorder
@@ -25,13 +26,15 @@ import io.github.xxfast.decompose.router.pages.rememberRouter
2526
import io.github.xxfast.decompose.router.screens.HomeScreens.Pages
2627
import io.github.xxfast.decompose.router.screens.HomeScreens.Slot
2728
import io.github.xxfast.decompose.router.screens.HomeScreens.Stack
29+
import io.github.xxfast.decompose.router.screens.HomeScreens.Items
30+
import io.github.xxfast.decompose.router.screens.items.ItemsScreen
2831
import io.github.xxfast.decompose.router.screens.pages.PagesScreen
2932
import io.github.xxfast.decompose.router.screens.slot.SlotScreen
3033
import io.github.xxfast.decompose.router.screens.stack.StackScreen
3134

3235
@Composable
3336
fun HomeScreen() {
34-
val pager: Router<HomeScreens> = rememberRouter { pagesOf(Stack, Pages, Slot) }
37+
val pager: Router<HomeScreens> = rememberRouter { pagesOf(Stack, Pages, Slot, Items) }
3538

3639
Scaffold(
3740
bottomBar = {
@@ -47,6 +50,7 @@ fun HomeScreen() {
4750
Stack -> Icons.Rounded.Reorder
4851
Pages -> Icons.Rounded.ImportContacts
4952
Slot -> Icons.Rounded.CropSquare
53+
Items -> Icons.AutoMirrored.Rounded.List
5054
},
5155
contentDescription = null,
5256
)
@@ -58,6 +62,7 @@ fun HomeScreen() {
5862
Stack -> BOTTOM_NAV_STACK
5963
Pages -> BOTTOM_NAV_PAGES
6064
Slot -> BOTTOM_NAV_SLOT
65+
Items -> BOTTOM_NAV_ITEMS
6166
}
6267
)
6368
)
@@ -84,6 +89,7 @@ fun HomeScreen() {
8489
Stack -> StackScreen()
8590
Pages -> PagesScreen()
8691
Slot -> SlotScreen()
92+
Items -> ItemsScreen()
8793
}
8894
}
8995
}

‎app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/HomeStateModels.kt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ enum class HomeScreens {
44
Stack,
55
Pages,
66
Slot,
7+
Items,
78
}

‎app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/TestTags.kt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ const val BOTTOM_NAV_BAR = "bottomNav"
66
const val BOTTOM_NAV_PAGES = "bottomNavPages"
77
const val BOTTOM_NAV_SLOT = "bottomNavSlot"
88
const val BOTTOM_NAV_STACK = "bottomNavStack"
9+
const val BOTTOM_NAV_ITEMS = "bottomNavItems"
10+
const val REMOVE_ITEM = "removeItem"
911
const val BOTTOM_SHEET = "bottomSheet"
1012
const val BUTTON_BOTTOM_SHEET = "btnBottomSheet"
1113
const val BUTTON_DIALOG = "btnDialog"
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package io.github.xxfast.decompose.router.screens.items
2+
3+
import androidx.compose.foundation.layout.Arrangement
4+
import androidx.compose.foundation.layout.Box
5+
import androidx.compose.foundation.layout.PaddingValues
6+
import androidx.compose.foundation.layout.WindowInsets
7+
import androidx.compose.foundation.layout.fillMaxSize
8+
import androidx.compose.foundation.layout.fillMaxWidth
9+
import androidx.compose.foundation.layout.height
10+
import androidx.compose.foundation.layout.padding
11+
import androidx.compose.foundation.lazy.LazyColumn
12+
import androidx.compose.foundation.lazy.LazyListState
13+
import androidx.compose.foundation.lazy.rememberLazyListState
14+
import androidx.compose.material.icons.Icons
15+
import androidx.compose.material.icons.rounded.Add
16+
import androidx.compose.material.icons.rounded.Remove
17+
import androidx.compose.material3.Card
18+
import androidx.compose.material3.ExperimentalMaterial3Api
19+
import androidx.compose.material3.FloatingActionButton
20+
import androidx.compose.material3.Icon
21+
import androidx.compose.material3.IconButton
22+
import androidx.compose.material3.MaterialTheme
23+
import androidx.compose.material3.Scaffold
24+
import androidx.compose.material3.Text
25+
import androidx.compose.material3.TopAppBar
26+
import androidx.compose.runtime.Composable
27+
import androidx.compose.runtime.collectAsState
28+
import androidx.compose.runtime.getValue
29+
import androidx.compose.runtime.mutableStateOf
30+
import androidx.compose.runtime.remember
31+
import androidx.compose.runtime.setValue
32+
import androidx.compose.ui.Alignment
33+
import androidx.compose.ui.Modifier
34+
import androidx.compose.ui.draw.clip
35+
import androidx.compose.ui.platform.testTag
36+
import androidx.compose.ui.unit.dp
37+
import com.arkivanov.decompose.ExperimentalDecomposeApi
38+
import com.arkivanov.decompose.router.items.setItems
39+
import io.github.xxfast.decompose.router.items.ItemsRouterLifecycleController
40+
import io.github.xxfast.decompose.router.items.Router
41+
import io.github.xxfast.decompose.router.items.items
42+
import io.github.xxfast.decompose.router.items.rememberRouter
43+
import io.github.xxfast.decompose.router.rememberOnRoute
44+
import io.github.xxfast.decompose.router.screens.FAB_ADD
45+
import io.github.xxfast.decompose.router.screens.LIST_TAG
46+
import io.github.xxfast.decompose.router.screens.REMOVE_ITEM
47+
import io.github.xxfast.decompose.router.screens.TITLE_BAR
48+
import io.github.xxfast.decompose.router.screens.TOOLBAR
49+
import io.github.xxfast.decompose.router.screens.items.item.ItemViewModel
50+
import io.github.xxfast.decompose.router.screens.items.item.ItemState
51+
52+
@OptIn(ExperimentalMaterial3Api::class, ExperimentalDecomposeApi::class)
53+
@Composable
54+
fun ItemsScreen() {
55+
var lastIndex: Int by remember { mutableStateOf(10) }
56+
val router: Router<Int> = rememberRouter { (1..lastIndex).toList() }
57+
val listState: LazyListState = rememberLazyListState()
58+
59+
Scaffold(
60+
topBar = {
61+
TopAppBar(
62+
modifier = Modifier.testTag(TOOLBAR),
63+
title = {
64+
Text(
65+
text = "Items",
66+
modifier = Modifier.testTag(TITLE_BAR)
67+
)
68+
},
69+
)
70+
},
71+
floatingActionButton = {
72+
FloatingActionButton(
73+
onClick = {
74+
router.setItems { it + (++lastIndex) }
75+
},
76+
content = { Icon(Icons.Rounded.Add, null) },
77+
modifier = Modifier.testTag(FAB_ADD)
78+
)
79+
},
80+
contentWindowInsets = WindowInsets(0, 0, 0, 0)
81+
) { paddingValues ->
82+
LazyColumn(
83+
state = listState,
84+
modifier = Modifier
85+
.padding(paddingValues)
86+
.fillMaxSize()
87+
.testTag(LIST_TAG),
88+
verticalArrangement = Arrangement.spacedBy(8.dp),
89+
contentPadding = PaddingValues(16.dp),
90+
) {
91+
items(
92+
router = router,
93+
key = { it }
94+
) { item ->
95+
val itemComponent: ItemViewModel = rememberOnRoute(key = "item_$item") {
96+
ItemViewModel(this)
97+
}
98+
99+
val state: ItemState by itemComponent.states.collectAsState()
100+
101+
Card(
102+
modifier = Modifier
103+
.height(128.dp)
104+
.fillMaxWidth()
105+
.clip(MaterialTheme.shapes.medium)
106+
.animateItem(),
107+
) {
108+
Box(
109+
modifier = Modifier.fillMaxSize()
110+
) {
111+
IconButton(
112+
onClick = {
113+
router.setItems { it - item }
114+
},
115+
modifier = Modifier
116+
.align(Alignment.TopEnd)
117+
.testTag("$REMOVE_ITEM$item")
118+
) {
119+
Icon(
120+
imageVector = Icons.Rounded.Remove,
121+
contentDescription = null,
122+
tint = MaterialTheme.colorScheme.error
123+
)
124+
}
125+
126+
Text(
127+
text = "#$item - ${state.tick}",
128+
style = MaterialTheme.typography.displayMedium,
129+
modifier = Modifier
130+
.align(Alignment.Center)
131+
.padding(8.dp)
132+
)
133+
}
134+
}
135+
}
136+
}
137+
}
138+
139+
ItemsRouterLifecycleController(
140+
router = router,
141+
lazyListState = listState,
142+
itemIndexConverter = { it },
143+
forwardPreloadCount = 3,
144+
backwardPreloadCount = 3
145+
)
146+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package io.github.xxfast.decompose.router.screens.items.item
2+
3+
import kotlinx.serialization.Serializable
4+
5+
@Serializable data class ItemState(val tick: Int)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package io.github.xxfast.decompose.router.screens.items.item
2+
3+
import io.github.xxfast.decompose.router.RouterContext
4+
import io.github.xxfast.decompose.router.state
5+
import kotlinx.coroutines.CoroutineScope
6+
import kotlinx.coroutines.Dispatchers
7+
import kotlinx.coroutines.delay
8+
import kotlinx.coroutines.flow.MutableStateFlow
9+
import kotlinx.coroutines.flow.StateFlow
10+
import kotlinx.coroutines.isActive
11+
import kotlinx.coroutines.launch
12+
import kotlin.coroutines.CoroutineContext
13+
import kotlin.time.Duration.Companion.seconds
14+
15+
class ItemViewModel(context: RouterContext): CoroutineScope {
16+
private val initialState: ItemState = context.state(ItemState(0)) { states.value }
17+
private val _state: MutableStateFlow<ItemState> = MutableStateFlow(initialState)
18+
val states: StateFlow<ItemState> = _state
19+
20+
override val coroutineContext: CoroutineContext = Dispatchers.Main
21+
22+
init {
23+
launch {
24+
while (isActive) {
25+
delay(1.seconds)
26+
val previous: ItemState = _state.value
27+
val updated: ItemState = previous.copy(tick = previous.tick + 1)
28+
_state.emit(updated)
29+
}
30+
}
31+
}
32+
}

‎decompose-router/api/desktop/decompose-router.api‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,28 @@ public final class io/github/xxfast/decompose/router/RouterContextKt {
2727
public static final fun getLocalRouterContext ()Landroidx/compose/runtime/ProvidableCompositionLocal;
2828
}
2929

30+
public final class io/github/xxfast/decompose/router/items/ItemsRouterLifecycleControllerKt {
31+
public static final fun ItemsRouterLifecycleController (Lio/github/xxfast/decompose/router/items/Router;Landroidx/compose/foundation/lazy/LazyListState;Lkotlin/jvm/functions/Function1;IILandroidx/compose/runtime/Composer;II)V
32+
public static final fun ItemsRouterLifecycleController (Lio/github/xxfast/decompose/router/items/Router;Landroidx/compose/foundation/lazy/grid/LazyGridState;Lkotlin/jvm/functions/Function1;IILandroidx/compose/runtime/Composer;II)V
33+
}
34+
35+
public final class io/github/xxfast/decompose/router/items/RoutedItemsKt {
36+
public static final fun items (Landroidx/compose/foundation/lazy/LazyListScope;Lio/github/xxfast/decompose/router/items/Router;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;)V
37+
public static final fun items (Landroidx/compose/foundation/lazy/grid/LazyGridScope;Lio/github/xxfast/decompose/router/items/Router;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;)V
38+
public static synthetic fun items$default (Landroidx/compose/foundation/lazy/LazyListScope;Lio/github/xxfast/decompose/router/items/Router;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;ILjava/lang/Object;)V
39+
public static synthetic fun items$default (Landroidx/compose/foundation/lazy/grid/LazyGridScope;Lio/github/xxfast/decompose/router/items/Router;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;ILjava/lang/Object;)V
40+
}
41+
42+
public final class io/github/xxfast/decompose/router/items/Router : com/arkivanov/decompose/router/items/ItemsNavigation {
43+
public static final field $stable I
44+
public fun <init> (Lcom/arkivanov/decompose/router/items/ItemsNavigation;Lcom/arkivanov/decompose/router/items/LazyChildItems;Lcom/arkivanov/essenty/lifecycle/Lifecycle;)V
45+
public final fun getContext (Ljava/lang/Object;)Lio/github/xxfast/decompose/router/RouterContext;
46+
public final fun getItems ()Landroidx/compose/runtime/State;
47+
public final fun getLazyItems ()Lcom/arkivanov/decompose/router/items/LazyChildItems;
48+
public fun navigate (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V
49+
public fun subscribe (Lkotlin/jvm/functions/Function1;)Lcom/arkivanov/decompose/Cancellation;
50+
}
51+
3052
public final class io/github/xxfast/decompose/router/pages/RoutedContentKt {
3153
public static final fun RoutedContent (Lio/github/xxfast/decompose/router/pages/Router;Landroidx/compose/ui/Modifier;Lcom/arkivanov/decompose/extensions/compose/pages/PagesScrollAnimation;Lkotlin/jvm/functions/Function6;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V
3254
}

0 commit comments

Comments
 (0)