Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ jobs:
sudo udevadm trigger --name-match=kvm

- name: Instrumentation tests
continue-on-error: ${{ matrix.api-level == 25}} # TODO: Figure out why this fails on API 25
continue-on-error: ${{ matrix.api-level == 25 }} # TODO: Figure out why this fails on API 25
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ matrix.api-level }}
Expand Down
42 changes: 36 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,12 @@ A detailed breakdown available in this [Medium article](https://proandroiddev.co

## At a glance


```kotlin
// Declare your screen configurations for type-safety
@Serializable
sealed class Screen {
object List : Screen()
// Declare your screen configurations as @Serializable for type-safety
@Serializable
sealed class Screen : Parcelable {
data object List : Screen()
data class Details(val detail: String) : Screen()
}

Expand All @@ -48,13 +49,42 @@ fun ListDetailScreen() {
when (screen) {
List -> ListScreen(
// Navigate by pushing new configurations on the router 🧭
onSelectItem = { detail -> router.push(detail) }
onSelectItem = { detail -> router.push(detail) }
)

is Details -> DetailsScreen(screen.detail)
}
}
}

@Composable
fun DetailsScreen(detail: String) {
// 📦 Scope an instance (a view model, a state-holder or whatever) to a route with [rememberOnRoute]
// This makes your instances survive configuration changes (on android) 🔁
// And holds-on the instance as long as it is in the backstack 🔗
// Pass in key if you want to reissue a new instance when key changes 🔑 (optional)
val viewModel: DetailViewModel = rememberOnRoute(key = detail) { // this: RouterContext
DetailViewModel(this, detail)
// Optional, if you want your coroutine scope to be cancelled when the screen is removed from the backstack
.apply { doOnDestroy { cancel() } }
}

val state: DetailState by viewModel.states.collectAsState()

Text(text = state.detail)
}

class DetailViewModel(context: RouterContext, detail: String): CoroutineScope {
// Optional, if you want to scope your coroutines to the lifecycle of this screen
override val coroutineContext: CoroutineContext = Dispatchers.Main + SupervisorJob()

// Optional, if you want your state to survive process death ☠️
// Derive your initial state from [RouterContext.state]
private val initialState: DetailState = context.state(DetailState(detail)) { states.value }
private val stateFlow = MutableStateFlow(initialState)

val states: StateFlow<DetailState> = stateFlow
}
```

### Installation
Expand Down
Original file line number Diff line number Diff line change
@@ -1,32 +1,34 @@
package io.github.xxfast.decompose.router.app

import androidx.compose.ui.test.hasTestTag
import io.github.xxfast.decompose.router.screens.BACK_BUTTON_TAG
import io.github.xxfast.decompose.router.screens.BUTTON_BACK
import io.github.xxfast.decompose.router.screens.BOTTOM_NAV_BAR
import io.github.xxfast.decompose.router.screens.BOTTOM_NAV_PAGES
import io.github.xxfast.decompose.router.screens.BOTTOM_NAV_SLOT
import io.github.xxfast.decompose.router.screens.BOTTOM_NAV_STACK
import io.github.xxfast.decompose.router.screens.BOTTOM_SHEET
import io.github.xxfast.decompose.router.screens.BUTTON_BOTTOM_SHEET
import io.github.xxfast.decompose.router.screens.BUTTON_DIALOG
import io.github.xxfast.decompose.router.screens.DETAILS_TAG
import io.github.xxfast.decompose.router.screens.BUTTON_FORWARD
import io.github.xxfast.decompose.router.screens.DETAILS
import io.github.xxfast.decompose.router.screens.DIALOG
import io.github.xxfast.decompose.router.screens.FAB_ADD
import io.github.xxfast.decompose.router.screens.LIST_TAG
import io.github.xxfast.decompose.router.screens.PAGER
import io.github.xxfast.decompose.router.screens.TITLE_BAR_TAG
import io.github.xxfast.decompose.router.screens.TITLE_BAR

internal val backButton = hasTestTag(BACK_BUTTON_TAG)
internal val backButton = hasTestTag(BUTTON_BACK)
internal val forwardButton = hasTestTag(BUTTON_FORWARD)
internal val bottomNav = hasTestTag(BOTTOM_NAV_BAR)
internal val bottomNavPagesItem = hasTestTag(BOTTOM_NAV_PAGES)
internal val bottomNavSlotItem = hasTestTag(BOTTOM_NAV_SLOT)
internal val bottomNavStackItem = hasTestTag(BOTTOM_NAV_STACK)
internal val bottomSheet = hasTestTag(BOTTOM_SHEET)
internal val buttonBottomSheet = hasTestTag(BUTTON_BOTTOM_SHEET)
internal val buttonDialog = hasTestTag(BUTTON_DIALOG)
internal val details = hasTestTag(DETAILS_TAG)
internal val details = hasTestTag(DETAILS)
internal val dialog = hasTestTag(DIALOG)
internal val fabAdd = hasTestTag(FAB_ADD)
internal val lazyColumn = hasTestTag(LIST_TAG)
internal val pager = hasTestTag(PAGER)
internal val titleBar = hasTestTag(TITLE_BAR_TAG)
internal val titleBar = hasTestTag(TITLE_BAR)
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@ class TestNestedRouters {
}

// Go to 5th detail screen
var testItem = "5"
val testItem = "5"
onNode(lazyColumn).performScrollToNode(hasText(testItem))
onNode(hasText(testItem)).performClick()
onNode(titleBar).assertExists().assertTextEquals(testItem)
onNode(titleBar).assertExists().assertTextContains("#$testItem")
onNode(details).assertExists().assertTextContains("Item@", substring = true)

// Go to pages and swipe to the 5th page
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,13 @@ import android.content.pm.ActivityInfo
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.test.ext.junit.rules.ActivityScenarioRule
import io.github.xxfast.decompose.router.screens.BACK_BUTTON_TAG
import io.github.xxfast.decompose.router.screens.BOTTOM_NAV_BAR
import io.github.xxfast.decompose.router.screens.BOTTOM_NAV_PAGES
import io.github.xxfast.decompose.router.screens.BOTTOM_NAV_SLOT
import io.github.xxfast.decompose.router.screens.BOTTOM_NAV_STACK
import io.github.xxfast.decompose.router.screens.FAB_ADD
import io.github.xxfast.decompose.router.screens.LIST_TAG
import io.github.xxfast.decompose.router.screens.TITLE_BAR_TAG
import kotlinx.coroutines.delay
import org.junit.Rule
import org.junit.Test

Expand All @@ -45,7 +37,7 @@ class TestStackRouter {
var testItem = "4"
onNode(lazyColumn).performScrollToNode(hasText(testItem))
onNode(hasText(testItem)).performClick()
onNode(titleBar).assertExists().assertTextEquals(testItem)
onNode(titleBar).assertExists().assertTextEquals("#$testItem")
onNode(details).assertExists().assertTextContains("Item@", substring = true)

// Navigate back
Expand All @@ -62,7 +54,7 @@ class TestStackRouter {
testItem = "5"
onNode(lazyColumn).performScrollToNode(hasText(testItem))
onNode(hasText(testItem)).performClick()
onNode(titleBar).assertExists().assertTextEquals(testItem)
onNode(titleBar).assertExists().assertTextEquals("#$testItem")
onNode(details).assertExists().assertTextContains("Item@", substring = true)

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

// Trigger configuration change and verify if the state and scroll position is restored back on the list screen
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
onNode(titleBar).assertExists().assertTextEquals(testItem)
onNode(hasText(testItem)).assertExists()
onNode(titleBar).assertExists().assertTextEquals("#$testItem")
onNode(hasText("#$testItem")).assertExists()

// Trigger configuration change again and verify scroll position is restored
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
onNode(hasText(testItem)).assertExists()
onNode(hasText("#$testItem")).assertExists()

// Repeat the same test but this time navigate back with gestures
activityRule.scenario.onActivity { activity ->
Expand All @@ -113,7 +105,7 @@ class TestStackRouter {
testItem = "9"
onNode(lazyColumn).performScrollToNode(hasText(testItem))
onNode(hasText(testItem)).performClick()
onNode(titleBar).assertExists().assertTextEquals(testItem)
onNode(titleBar).assertExists().assertTextEquals("#$testItem")
onNode(details).assertExists().assertTextContains("Item@", substring = true)
activityRule.scenario.onActivity { activity ->
activity.onBackPressedDispatcher.onBackPressed()
Expand All @@ -122,4 +114,42 @@ class TestStackRouter {
onNode(titleBar).assertExists().assertTextContains("Stack", substring = true)
onNode(hasText(testItem)).assertExists()
}

@OptIn(ExperimentalTestApi::class)
@Test
fun testCoroutineScopeCancelledWhenRemovedFromStack(): Unit = with(composeRule) {
// Navigate to the 4th item and verify
var testItem = "4"
onNode(lazyColumn).performScrollToNode(hasText(testItem))
onNode(hasText(testItem)).performClick()
onNode(titleBar).assertExists().assertTextEquals("#$testItem")
onNode(details).assertExists().assertTextContains("Item@", substring = true)
onNode(details).assertExists().assertTextContains("been in the stack for 0s", substring = true)

// Go to the next item in the stack
onNode(forwardButton).performClick()
testItem = "5"
onNode(titleBar).assertExists().assertTextEquals("#$testItem")
onNode(details).assertExists().assertTextContains("Item@", substring = true)
onNode(details).assertExists().assertTextContains("been in the stack for 0s", substring = true)

// wait here for a bit
waitUntilAtLeastOneExists(hasText("been in the stack for 1s", substring = true))

// Go back to the 4th item, and verify the coroutine scope is not cancelled
onNode(backButton).performClick()
testItem = "4"
onNode(titleBar).assertExists().assertTextEquals("#$testItem")
onNode(details).assertExists().assertTextContains("Item@", substring = true)
onNode(details).assertExists().assertTextContains("been in the stack for 1s", substring = true)

// Go back to list screen and come back to the 4th item, and verify the coroutine scope is cancelled
onNode(backButton).performClick()
onNode(lazyColumn).assertExists()
onNode(lazyColumn).performScrollToNode(hasText(testItem))
onNode(hasText(testItem)).performClick()
onNode(titleBar).assertExists().assertTextEquals("#$testItem")
onNode(details).assertExists().assertTextContains("Item@", substring = true)
onNode(details).assertExists().assertTextContains("been in the stack for 0s", substring = true)
}
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
package io.github.xxfast.decompose.router.screens

const val BACK_BUTTON_TAG = "back"
const val BUTTON_BACK = "back"
const val BUTTON_FORWARD = "forward"
const val BOTTOM_NAV_BAR = "bottomNav"
const val BOTTOM_NAV_PAGES = "bottomNavPages"
const val BOTTOM_NAV_SLOT = "bottomNavSlot"
const val BOTTOM_NAV_STACK = "bottomNavStack"
const val BOTTOM_SHEET = "bottomSheet"
const val BUTTON_BOTTOM_SHEET = "btnBottomSheet"
const val BUTTON_DIALOG = "btnDialog"
const val DETAILS_TAG = "details"
const val DETAILS = "details"
const val DIALOG = "dialog"
const val FAB_ADD = "fabAdd"
const val LIST_TAG = "list"
const val PAGER = "pager"
const val TITLE_BAR_TAG = "titleBar"
const val TOOLBAR_TAG = "toolbar"
const val TITLE_BAR = "titleBar"
const val TOOLBAR = "toolbar"
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import io.github.xxfast.decompose.router.screens.BOTTOM_SHEET
import io.github.xxfast.decompose.router.screens.BUTTON_BOTTOM_SHEET
import io.github.xxfast.decompose.router.screens.BUTTON_DIALOG
import io.github.xxfast.decompose.router.screens.DIALOG
import io.github.xxfast.decompose.router.screens.TITLE_BAR_TAG
import io.github.xxfast.decompose.router.screens.TITLE_BAR
import io.github.xxfast.decompose.router.slot.RoutedContent
import io.github.xxfast.decompose.router.slot.Router
import io.github.xxfast.decompose.router.slot.rememberRouter
Expand All @@ -46,7 +46,7 @@ fun SlotScreen() {
title = {
Text(
text = "Slot",
modifier = Modifier.testTag(TITLE_BAR_TAG)
modifier = Modifier.testTag(TITLE_BAR)
)
}
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import io.github.xxfast.decompose.router.LocalRouterContext
import io.github.xxfast.decompose.router.screens.stack.StackScreens.Details
import io.github.xxfast.decompose.router.screens.stack.StackScreens.List
import io.github.xxfast.decompose.router.screens.stack.details.DetailScreen
import io.github.xxfast.decompose.router.screens.stack.details.DetailView
import io.github.xxfast.decompose.router.screens.stack.list.ListScreen
import io.github.xxfast.decompose.router.stack.RoutedContent
import io.github.xxfast.decompose.router.stack.Router
Expand All @@ -40,7 +41,11 @@ fun StackScreen() {

is Details -> DetailScreen(
item = screen.item,
onBack = { router.pop() }
onBack = { router.pop() },
onNext = {
val next = Details(Item(screen.item.index + 1))
router.push(next)
}
)
}
}
Expand Down
Loading
Loading