Skip to content

Commit b748c8c

Browse files
authored
Add inline variant for rememberOnRoute (#104)
* Add inline variant for remember on route * Update list detail screens to demonstrate scope lifecycles * Update build action to ignore failure on android API 25 * Fix test cases * Update documentation * Add test case to verify scope cancellation
1 parent 159751c commit b748c8c

File tree

19 files changed

+298
-129
lines changed

19 files changed

+298
-129
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ jobs:
106106
sudo udevadm trigger --name-match=kvm
107107
108108
- name: Instrumentation tests
109-
continue-on-error: ${{ matrix.api-level == 25}} # TODO: Figure out why this fails on API 25
109+
continue-on-error: ${{ matrix.api-level == 25 }} # TODO: Figure out why this fails on API 25
110110
uses: reactivecircus/android-emulator-runner@v2
111111
with:
112112
api-level: ${{ matrix.api-level }}

README.md

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,12 @@ A detailed breakdown available in this [Medium article](https://proandroiddev.co
3030

3131
## At a glance
3232

33+
3334
```kotlin
34-
// Declare your screen configurations for type-safety
35-
@Serializable
36-
sealed class Screen {
37-
object List : Screen()
35+
// Declare your screen configurations as @Serializable for type-safety
36+
@Serializable
37+
sealed class Screen : Parcelable {
38+
data object List : Screen()
3839
data class Details(val detail: String) : Screen()
3940
}
4041

@@ -48,13 +49,42 @@ fun ListDetailScreen() {
4849
when (screen) {
4950
List -> ListScreen(
5051
// Navigate by pushing new configurations on the router 🧭
51-
onSelectItem = { detail -> router.push(detail) }
52+
onSelectItem = { detail -> router.push(detail) }
5253
)
53-
54+
5455
is Details -> DetailsScreen(screen.detail)
5556
}
5657
}
5758
}
59+
60+
@Composable
61+
fun DetailsScreen(detail: String) {
62+
// 📦 Scope an instance (a view model, a state-holder or whatever) to a route with [rememberOnRoute]
63+
// This makes your instances survive configuration changes (on android) 🔁
64+
// And holds-on the instance as long as it is in the backstack 🔗
65+
// Pass in key if you want to reissue a new instance when key changes 🔑 (optional)
66+
val viewModel: DetailViewModel = rememberOnRoute(key = detail) { // this: RouterContext
67+
DetailViewModel(this, detail)
68+
// Optional, if you want your coroutine scope to be cancelled when the screen is removed from the backstack
69+
.apply { doOnDestroy { cancel() } }
70+
}
71+
72+
val state: DetailState by viewModel.states.collectAsState()
73+
74+
Text(text = state.detail)
75+
}
76+
77+
class DetailViewModel(context: RouterContext, detail: String): CoroutineScope {
78+
// Optional, if you want to scope your coroutines to the lifecycle of this screen
79+
override val coroutineContext: CoroutineContext = Dispatchers.Main + SupervisorJob()
80+
81+
// Optional, if you want your state to survive process death ☠️
82+
// Derive your initial state from [RouterContext.state]
83+
private val initialState: DetailState = context.state(DetailState(detail)) { states.value }
84+
private val stateFlow = MutableStateFlow(initialState)
85+
86+
val states: StateFlow<DetailState> = stateFlow
87+
}
5888
```
5989

6090
### Installation
Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,34 @@
11
package io.github.xxfast.decompose.router.app
22

33
import androidx.compose.ui.test.hasTestTag
4-
import io.github.xxfast.decompose.router.screens.BACK_BUTTON_TAG
4+
import io.github.xxfast.decompose.router.screens.BUTTON_BACK
55
import io.github.xxfast.decompose.router.screens.BOTTOM_NAV_BAR
66
import io.github.xxfast.decompose.router.screens.BOTTOM_NAV_PAGES
77
import io.github.xxfast.decompose.router.screens.BOTTOM_NAV_SLOT
88
import io.github.xxfast.decompose.router.screens.BOTTOM_NAV_STACK
99
import io.github.xxfast.decompose.router.screens.BOTTOM_SHEET
1010
import io.github.xxfast.decompose.router.screens.BUTTON_BOTTOM_SHEET
1111
import io.github.xxfast.decompose.router.screens.BUTTON_DIALOG
12-
import io.github.xxfast.decompose.router.screens.DETAILS_TAG
12+
import io.github.xxfast.decompose.router.screens.BUTTON_FORWARD
13+
import io.github.xxfast.decompose.router.screens.DETAILS
1314
import io.github.xxfast.decompose.router.screens.DIALOG
1415
import io.github.xxfast.decompose.router.screens.FAB_ADD
1516
import io.github.xxfast.decompose.router.screens.LIST_TAG
1617
import io.github.xxfast.decompose.router.screens.PAGER
17-
import io.github.xxfast.decompose.router.screens.TITLE_BAR_TAG
18+
import io.github.xxfast.decompose.router.screens.TITLE_BAR
1819

19-
internal val backButton = hasTestTag(BACK_BUTTON_TAG)
20+
internal val backButton = hasTestTag(BUTTON_BACK)
21+
internal val forwardButton = hasTestTag(BUTTON_FORWARD)
2022
internal val bottomNav = hasTestTag(BOTTOM_NAV_BAR)
2123
internal val bottomNavPagesItem = hasTestTag(BOTTOM_NAV_PAGES)
2224
internal val bottomNavSlotItem = hasTestTag(BOTTOM_NAV_SLOT)
2325
internal val bottomNavStackItem = hasTestTag(BOTTOM_NAV_STACK)
2426
internal val bottomSheet = hasTestTag(BOTTOM_SHEET)
2527
internal val buttonBottomSheet = hasTestTag(BUTTON_BOTTOM_SHEET)
2628
internal val buttonDialog = hasTestTag(BUTTON_DIALOG)
27-
internal val details = hasTestTag(DETAILS_TAG)
29+
internal val details = hasTestTag(DETAILS)
2830
internal val dialog = hasTestTag(DIALOG)
2931
internal val fabAdd = hasTestTag(FAB_ADD)
3032
internal val lazyColumn = hasTestTag(LIST_TAG)
3133
internal val pager = hasTestTag(PAGER)
32-
internal val titleBar = hasTestTag(TITLE_BAR_TAG)
34+
internal val titleBar = hasTestTag(TITLE_BAR)

app/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/router/app/TestNestedRouters.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,10 @@ class TestNestedRouters {
3232
}
3333

3434
// Go to 5th detail screen
35-
var testItem = "5"
35+
val testItem = "5"
3636
onNode(lazyColumn).performScrollToNode(hasText(testItem))
3737
onNode(hasText(testItem)).performClick()
38-
onNode(titleBar).assertExists().assertTextEquals(testItem)
38+
onNode(titleBar).assertExists().assertTextContains("#$testItem")
3939
onNode(details).assertExists().assertTextContains("Item@", substring = true)
4040

4141
// Go to pages and swipe to the 5th page

app/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/router/app/TestStackRouter.kt

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,13 @@ import android.content.pm.ActivityInfo
44
import androidx.compose.ui.test.ExperimentalTestApi
55
import androidx.compose.ui.test.assertTextContains
66
import androidx.compose.ui.test.assertTextEquals
7-
import androidx.compose.ui.test.hasTestTag
87
import androidx.compose.ui.test.hasText
98
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
109
import androidx.compose.ui.test.junit4.createAndroidComposeRule
1110
import androidx.compose.ui.test.performClick
1211
import androidx.compose.ui.test.performScrollToNode
1312
import androidx.test.ext.junit.rules.ActivityScenarioRule
14-
import io.github.xxfast.decompose.router.screens.BACK_BUTTON_TAG
15-
import io.github.xxfast.decompose.router.screens.BOTTOM_NAV_BAR
16-
import io.github.xxfast.decompose.router.screens.BOTTOM_NAV_PAGES
17-
import io.github.xxfast.decompose.router.screens.BOTTOM_NAV_SLOT
18-
import io.github.xxfast.decompose.router.screens.BOTTOM_NAV_STACK
19-
import io.github.xxfast.decompose.router.screens.FAB_ADD
20-
import io.github.xxfast.decompose.router.screens.LIST_TAG
21-
import io.github.xxfast.decompose.router.screens.TITLE_BAR_TAG
13+
import kotlinx.coroutines.delay
2214
import org.junit.Rule
2315
import org.junit.Test
2416

@@ -45,7 +37,7 @@ class TestStackRouter {
4537
var testItem = "4"
4638
onNode(lazyColumn).performScrollToNode(hasText(testItem))
4739
onNode(hasText(testItem)).performClick()
48-
onNode(titleBar).assertExists().assertTextEquals(testItem)
40+
onNode(titleBar).assertExists().assertTextEquals("#$testItem")
4941
onNode(details).assertExists().assertTextContains("Item@", substring = true)
5042

5143
// Navigate back
@@ -62,7 +54,7 @@ class TestStackRouter {
6254
testItem = "5"
6355
onNode(lazyColumn).performScrollToNode(hasText(testItem))
6456
onNode(hasText(testItem)).performClick()
65-
onNode(titleBar).assertExists().assertTextEquals(testItem)
57+
onNode(titleBar).assertExists().assertTextEquals("#$testItem")
6658
onNode(details).assertExists().assertTextContains("Item@", substring = true)
6759

6860
// Navigate back and verify state and scroll position is restored
@@ -75,7 +67,7 @@ class TestStackRouter {
7567
testItem = "9"
7668
onNode(lazyColumn).performScrollToNode(hasText(testItem))
7769
onNode(hasText(testItem)).performClick()
78-
onNode(titleBar).assertExists().assertTextEquals(testItem)
70+
onNode(titleBar).assertExists().assertTextEquals("#$testItem")
7971
onNode(details).assertExists().assertTextContains("Item@", substring = true)
8072
activityRule.scenario.onActivity { activity ->
8173
activity.onBackPressedDispatcher.onBackPressed()
@@ -99,12 +91,12 @@ class TestStackRouter {
9991

10092
// Trigger configuration change and verify if the state and scroll position is restored back on the list screen
10193
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
102-
onNode(titleBar).assertExists().assertTextEquals(testItem)
103-
onNode(hasText(testItem)).assertExists()
94+
onNode(titleBar).assertExists().assertTextEquals("#$testItem")
95+
onNode(hasText("#$testItem")).assertExists()
10496

10597
// Trigger configuration change again and verify scroll position is restored
10698
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
107-
onNode(hasText(testItem)).assertExists()
99+
onNode(hasText("#$testItem")).assertExists()
108100

109101
// Repeat the same test but this time navigate back with gestures
110102
activityRule.scenario.onActivity { activity ->
@@ -113,7 +105,7 @@ class TestStackRouter {
113105
testItem = "9"
114106
onNode(lazyColumn).performScrollToNode(hasText(testItem))
115107
onNode(hasText(testItem)).performClick()
116-
onNode(titleBar).assertExists().assertTextEquals(testItem)
108+
onNode(titleBar).assertExists().assertTextEquals("#$testItem")
117109
onNode(details).assertExists().assertTextContains("Item@", substring = true)
118110
activityRule.scenario.onActivity { activity ->
119111
activity.onBackPressedDispatcher.onBackPressed()
@@ -122,4 +114,42 @@ class TestStackRouter {
122114
onNode(titleBar).assertExists().assertTextContains("Stack", substring = true)
123115
onNode(hasText(testItem)).assertExists()
124116
}
117+
118+
@OptIn(ExperimentalTestApi::class)
119+
@Test
120+
fun testCoroutineScopeCancelledWhenRemovedFromStack(): Unit = with(composeRule) {
121+
// Navigate to the 4th item and verify
122+
var testItem = "4"
123+
onNode(lazyColumn).performScrollToNode(hasText(testItem))
124+
onNode(hasText(testItem)).performClick()
125+
onNode(titleBar).assertExists().assertTextEquals("#$testItem")
126+
onNode(details).assertExists().assertTextContains("Item@", substring = true)
127+
onNode(details).assertExists().assertTextContains("been in the stack for 0s", substring = true)
128+
129+
// Go to the next item in the stack
130+
onNode(forwardButton).performClick()
131+
testItem = "5"
132+
onNode(titleBar).assertExists().assertTextEquals("#$testItem")
133+
onNode(details).assertExists().assertTextContains("Item@", substring = true)
134+
onNode(details).assertExists().assertTextContains("been in the stack for 0s", substring = true)
135+
136+
// wait here for a bit
137+
waitUntilAtLeastOneExists(hasText("been in the stack for 1s", substring = true))
138+
139+
// Go back to the 4th item, and verify the coroutine scope is not cancelled
140+
onNode(backButton).performClick()
141+
testItem = "4"
142+
onNode(titleBar).assertExists().assertTextEquals("#$testItem")
143+
onNode(details).assertExists().assertTextContains("Item@", substring = true)
144+
onNode(details).assertExists().assertTextContains("been in the stack for 1s", substring = true)
145+
146+
// Go back to list screen and come back to the 4th item, and verify the coroutine scope is cancelled
147+
onNode(backButton).performClick()
148+
onNode(lazyColumn).assertExists()
149+
onNode(lazyColumn).performScrollToNode(hasText(testItem))
150+
onNode(hasText(testItem)).performClick()
151+
onNode(titleBar).assertExists().assertTextEquals("#$testItem")
152+
onNode(details).assertExists().assertTextContains("Item@", substring = true)
153+
onNode(details).assertExists().assertTextContains("been in the stack for 0s", substring = true)
154+
}
125155
}
Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
package io.github.xxfast.decompose.router.screens
22

3-
const val BACK_BUTTON_TAG = "back"
3+
const val BUTTON_BACK = "back"
4+
const val BUTTON_FORWARD = "forward"
45
const val BOTTOM_NAV_BAR = "bottomNav"
56
const val BOTTOM_NAV_PAGES = "bottomNavPages"
67
const val BOTTOM_NAV_SLOT = "bottomNavSlot"
78
const val BOTTOM_NAV_STACK = "bottomNavStack"
89
const val BOTTOM_SHEET = "bottomSheet"
910
const val BUTTON_BOTTOM_SHEET = "btnBottomSheet"
1011
const val BUTTON_DIALOG = "btnDialog"
11-
const val DETAILS_TAG = "details"
12+
const val DETAILS = "details"
1213
const val DIALOG = "dialog"
1314
const val FAB_ADD = "fabAdd"
1415
const val LIST_TAG = "list"
1516
const val PAGER = "pager"
16-
const val TITLE_BAR_TAG = "titleBar"
17-
const val TOOLBAR_TAG = "toolbar"
17+
const val TITLE_BAR = "titleBar"
18+
const val TOOLBAR = "toolbar"

app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/slot/SlotScreen.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import io.github.xxfast.decompose.router.screens.BOTTOM_SHEET
2929
import io.github.xxfast.decompose.router.screens.BUTTON_BOTTOM_SHEET
3030
import io.github.xxfast.decompose.router.screens.BUTTON_DIALOG
3131
import io.github.xxfast.decompose.router.screens.DIALOG
32-
import io.github.xxfast.decompose.router.screens.TITLE_BAR_TAG
32+
import io.github.xxfast.decompose.router.screens.TITLE_BAR
3333
import io.github.xxfast.decompose.router.slot.RoutedContent
3434
import io.github.xxfast.decompose.router.slot.Router
3535
import io.github.xxfast.decompose.router.slot.rememberRouter
@@ -46,7 +46,7 @@ fun SlotScreen() {
4646
title = {
4747
Text(
4848
text = "Slot",
49-
modifier = Modifier.testTag(TITLE_BAR_TAG)
49+
modifier = Modifier.testTag(TITLE_BAR)
5050
)
5151
}
5252
)

app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/stack/StackScreen.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import io.github.xxfast.decompose.router.LocalRouterContext
1414
import io.github.xxfast.decompose.router.screens.stack.StackScreens.Details
1515
import io.github.xxfast.decompose.router.screens.stack.StackScreens.List
1616
import io.github.xxfast.decompose.router.screens.stack.details.DetailScreen
17+
import io.github.xxfast.decompose.router.screens.stack.details.DetailView
1718
import io.github.xxfast.decompose.router.screens.stack.list.ListScreen
1819
import io.github.xxfast.decompose.router.stack.RoutedContent
1920
import io.github.xxfast.decompose.router.stack.Router
@@ -40,7 +41,11 @@ fun StackScreen() {
4041

4142
is Details -> DetailScreen(
4243
item = screen.item,
43-
onBack = { router.pop() }
44+
onBack = { router.pop() },
45+
onNext = {
46+
val next = Details(Item(screen.item.index + 1))
47+
router.push(next)
48+
}
4449
)
4550
}
4651
}

0 commit comments

Comments
 (0)