diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 028d513..6f023d2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 }} diff --git a/README.md b/README.md index 9a8094e..b374d6e 100644 --- a/README.md +++ b/README.md @@ -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() } @@ -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 = stateFlow +} ``` ### Installation diff --git a/app/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/router/app/TestElements.kt b/app/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/router/app/TestElements.kt index 8655f80..702eae8 100644 --- a/app/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/router/app/TestElements.kt +++ b/app/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/router/app/TestElements.kt @@ -1,7 +1,7 @@ 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 @@ -9,14 +9,16 @@ 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) @@ -24,9 +26,9 @@ 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) diff --git a/app/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/router/app/TestNestedRouters.kt b/app/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/router/app/TestNestedRouters.kt index e4482b5..0dd0700 100644 --- a/app/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/router/app/TestNestedRouters.kt +++ b/app/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/router/app/TestNestedRouters.kt @@ -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 diff --git a/app/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/router/app/TestStackRouter.kt b/app/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/router/app/TestStackRouter.kt index 10a75c0..458b1cd 100644 --- a/app/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/router/app/TestStackRouter.kt +++ b/app/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/router/app/TestStackRouter.kt @@ -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 @@ -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 @@ -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 @@ -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() @@ -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 -> @@ -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() @@ -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) + } } diff --git a/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/TestTags.kt b/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/TestTags.kt index 30880ab..1b8cdf2 100644 --- a/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/TestTags.kt +++ b/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/TestTags.kt @@ -1,6 +1,7 @@ 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" @@ -8,10 +9,10 @@ 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" diff --git a/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/slot/SlotScreen.kt b/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/slot/SlotScreen.kt index 6de9d6f..3b6cb2e 100644 --- a/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/slot/SlotScreen.kt +++ b/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/slot/SlotScreen.kt @@ -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 @@ -46,7 +46,7 @@ fun SlotScreen() { title = { Text( text = "Slot", - modifier = Modifier.testTag(TITLE_BAR_TAG) + modifier = Modifier.testTag(TITLE_BAR) ) } ) diff --git a/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/stack/StackScreen.kt b/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/stack/StackScreen.kt index 4e8ce0f..060d5b5 100644 --- a/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/stack/StackScreen.kt +++ b/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/stack/StackScreen.kt @@ -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 @@ -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) + } ) } } diff --git a/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/stack/details/DetailScreen.kt b/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/stack/details/DetailScreen.kt index 294c7b4..9b77330 100644 --- a/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/stack/details/DetailScreen.kt +++ b/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/stack/details/DetailScreen.kt @@ -4,58 +4,94 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp -import io.github.xxfast.decompose.router.screens.BACK_BUTTON_TAG -import io.github.xxfast.decompose.router.screens.DETAILS_TAG -import io.github.xxfast.decompose.router.screens.TITLE_BAR_TAG -import io.github.xxfast.decompose.router.screens.TOOLBAR_TAG +import com.arkivanov.essenty.lifecycle.doOnDestroy +import io.github.xxfast.decompose.router.rememberOnRoute +import io.github.xxfast.decompose.router.screens.BUTTON_BACK +import io.github.xxfast.decompose.router.screens.BUTTON_FORWARD +import io.github.xxfast.decompose.router.screens.DETAILS +import io.github.xxfast.decompose.router.screens.TITLE_BAR +import io.github.xxfast.decompose.router.screens.TOOLBAR import io.github.xxfast.decompose.router.screens.stack.Item +import kotlinx.coroutines.cancel -@OptIn(ExperimentalMaterial3Api::class) @Composable fun DetailScreen( item: Item, onBack: () -> Unit, + onNext: () -> Unit, +) { + val viewModel: DetailsViewModel = rememberOnRoute { + DetailsViewModel(this, item) + .apply { doOnDestroy { cancel() } } + } + + val state: DetailState by viewModel.states.collectAsState() + + DetailView( + state = state, + onBack = onBack, + onNext = onNext, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DetailView( + state: DetailState, + onBack: () -> Unit, + onNext: () -> Unit, ) { Scaffold( topBar = { TopAppBar( - modifier = Modifier.testTag(TOOLBAR_TAG), + modifier = Modifier.testTag(TOOLBAR), title = { Text( - text = item.index.toString(), - modifier = Modifier.testTag(TITLE_BAR_TAG) + text = "#${state.item.index}", + modifier = Modifier.testTag(TITLE_BAR) ) }, navigationIcon = { IconButton( modifier = Modifier - .testTag(BACK_BUTTON_TAG), + .testTag(BUTTON_BACK), onClick = onBack ) { - Icon(Icons.Default.ArrowBack, null) + Icon(Icons.AutoMirrored.Filled.ArrowBack, null) + } + }, + actions = { + IconButton( + modifier = Modifier + .testTag(BUTTON_FORWARD), + onClick = onNext + ) { + Icon(Icons.AutoMirrored.Filled.ArrowForward, null) } }) }) { paddingValues -> Box(modifier = Modifier.padding(paddingValues).fillMaxSize()) { Text( - text = item.toString(), + text = "${state.item} been in the stack for ${state.age}", modifier = Modifier .padding(16.dp) .align(Alignment.Center) - .testTag(DETAILS_TAG) + .testTag(DETAILS) ) } } diff --git a/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/stack/details/DetailStateModels.kt b/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/stack/details/DetailStateModels.kt index 0399f3a..69d53a6 100644 --- a/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/stack/details/DetailStateModels.kt +++ b/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/stack/details/DetailStateModels.kt @@ -1,6 +1,11 @@ package io.github.xxfast.decompose.router.screens.stack.details +import io.github.xxfast.decompose.router.screens.stack.Item import kotlinx.serialization.Serializable +import kotlin.time.Duration @Serializable -data class DetailState(val count: Int) +data class DetailState( + val item: Item, + val age: Duration = Duration.ZERO +) diff --git a/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/stack/details/DetailsViewModel.kt b/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/stack/details/DetailsViewModel.kt new file mode 100644 index 0000000..f19e4aa --- /dev/null +++ b/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/stack/details/DetailsViewModel.kt @@ -0,0 +1,32 @@ +package io.github.xxfast.decompose.router.screens.stack.details + +import io.github.xxfast.decompose.router.RouterContext +import io.github.xxfast.decompose.router.screens.stack.Item +import io.github.xxfast.decompose.router.state +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.isActive +import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration.Companion.seconds + +class DetailsViewModel(context: RouterContext, item: Item) : CoroutineScope { + private val initialState: DetailState = context.state(DetailState(item)) { states.value } + override val coroutineContext: CoroutineContext = Dispatchers.Main + SupervisorJob() + + val states: StateFlow = flow { + var value = DetailState(item = item, age = 0.seconds) + emit(value) + while (isActive) { + delay(1.seconds) + value = value.copy(age = value.age + 1.seconds) + emit(value) + } + } + .stateIn(this, started = SharingStarted.Lazily, initialValue = initialState) +} diff --git a/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/stack/list/ListScreen.kt b/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/stack/list/ListScreen.kt index a2a0dc1..4a538e4 100644 --- a/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/stack/list/ListScreen.kt +++ b/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/stack/list/ListScreen.kt @@ -35,33 +35,48 @@ import androidx.compose.ui.unit.dp import io.github.xxfast.decompose.router.rememberOnRoute 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 io.github.xxfast.decompose.router.screens.TOOLBAR_TAG +import io.github.xxfast.decompose.router.screens.TITLE_BAR +import io.github.xxfast.decompose.router.screens.TOOLBAR import io.github.xxfast.decompose.router.screens.stack.Item import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ListScreen( onSelect: (screen: Item) -> Unit, ) { - val listComponent: ListComponent = rememberOnRoute(ListComponent::class) { context -> - ListComponent(context) + val listComponent: ListViewModel = rememberOnRoute { + ListViewModel(this) } val state: ListState by listComponent.states.collectAsState() + + ListView( + state = state, + onSelect = onSelect, + onAdd = { listComponent.add() } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ListView( + state: ListState, + onSelect: (screen: Item) -> Unit, + onAdd: () -> Unit, +) { + val listState: LazyListState = rememberLazyListState() val coroutineScope: CoroutineScope = rememberCoroutineScope() Scaffold( topBar = { TopAppBar( - modifier = Modifier.testTag(TOOLBAR_TAG), + modifier = Modifier.testTag(TOOLBAR), title = { Text( text = "Stack (${state.screens.size})", - modifier = Modifier.testTag(TITLE_BAR_TAG) + modifier = Modifier.testTag(TITLE_BAR) ) }, ) @@ -69,7 +84,7 @@ fun ListScreen( floatingActionButton = { FloatingActionButton( onClick = { - listComponent.add() + onAdd() coroutineScope.launch { listState.animateScrollToItem(state.screens.lastIndex) } }, content = { Icon(Icons.Rounded.Add, null) }, diff --git a/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/stack/list/ListComponent.kt b/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/stack/list/ListViewModel.kt similarity index 87% rename from app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/stack/list/ListComponent.kt rename to app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/stack/list/ListViewModel.kt index 18b398e..1fe5217 100644 --- a/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/stack/list/ListComponent.kt +++ b/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/stack/list/ListViewModel.kt @@ -1,9 +1,8 @@ package io.github.xxfast.decompose.router.screens.stack.list -import com.arkivanov.decompose.ComponentContext import io.github.xxfast.decompose.router.RouterContext -import io.github.xxfast.decompose.router.state import io.github.xxfast.decompose.router.screens.stack.Item +import io.github.xxfast.decompose.router.state import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -11,7 +10,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlin.coroutines.CoroutineContext -class ListComponent(context: RouterContext) : ComponentContext by context, CoroutineScope { +class ListViewModel(context: RouterContext): CoroutineScope { private val initialState: ListState = context.state(ListState()) { states.value } private val _state: MutableStateFlow = MutableStateFlow(initialState) val states: StateFlow = _state diff --git a/decompose-router/src/commonMain/kotlin/io/github/xxfast/decompose/router/RememberOnRoute.kt b/decompose-router/src/commonMain/kotlin/io/github/xxfast/decompose/router/RememberOnRoute.kt index b5a746f..5931a34 100644 --- a/decompose-router/src/commonMain/kotlin/io/github/xxfast/decompose/router/RememberOnRoute.kt +++ b/decompose-router/src/commonMain/kotlin/io/github/xxfast/decompose/router/RememberOnRoute.kt @@ -5,20 +5,35 @@ import androidx.compose.runtime.DisallowComposableCalls import androidx.compose.runtime.remember import com.arkivanov.essenty.instancekeeper.InstanceKeeper import com.arkivanov.essenty.instancekeeper.getOrCreate +import kotlinx.serialization.Serializable import kotlin.reflect.KClass + +/*** + * Scopes instance of [T] to the current route + * @param key key to remember the instance with. Defaults to [T]'s [io.github.xxfast.decompose.router.key] + * @param block lambda to create an instance of [T] with a given [RouterContext] + */ +@Suppress("DEPRECATION") // For migration purposes +@Composable +inline fun rememberOnRoute( + key: Any = T::class, + noinline block: @DisallowComposableCalls RouterContext.() -> T +): T = rememberOnRoute(T::class, key, block) + /*** * Scopes instance of [T] to the current route * * @param type class of [T] instance - * @param key key to remember the instance with. Defaults to [type]'s key + * @param key key to remember the instance with. Defaults to [type]'s [io.github.xxfast.decompose.router.key] * @param block lambda to create an instance of [T] with a given [RouterContext] */ +@Deprecated("Use the inline variant above") @Composable fun rememberOnRoute( type: KClass, key: Any = type.key, - block: @DisallowComposableCalls (context: RouterContext) -> T + block: @DisallowComposableCalls RouterContext.() -> T ): T { class RouteInstance(val instance: T): InstanceKeeper.Instance val routerContext: RouterContext = LocalRouterContext.current diff --git a/docs/topics/managing-screen-state.md b/docs/topics/managing-screen-state.md index b50f7f5..f49fe59 100644 --- a/docs/topics/managing-screen-state.md +++ b/docs/topics/managing-screen-state.md @@ -10,30 +10,42 @@ If you want your screen level state holder to be scoped to a given screen, use ` 2. Holds-on the instance as long as it is in the backstack ```kotlin -class List +class ListViewModel @Composable fun ListScreen() { - val list: List = rememberOnRoute(List::class) { _ -> List() } + val viewModel: ListViewModel = rememberOnRoute { ListViewModel() } } ``` -If you want this instance to be recomputed, you can also provide a key to it +If you want this instance to be recomputed, you can also provide a key to it + ```kotlin -class Details(val id: String) +class DetailsViewModel(val id: String) @Composable fun DetailsScreen(id: String) { - val details: Details = rememberOnRoute(Details::class, key = id) { _ -> Details(id) } + val viewModel: DetailsViewModel = rememberOnRoute(key = id) { DetailsViewModel(id) } +} +``` + +### Cancelling Coroutines when Screen is Removed + +If you want to scope your coroutines to the lifecycle of this screen, you can use `doOnDestroy` to cancel the coroutine scope when the screen is removed from the backstack + +```kotlin +class DetailsViewModel(val id: String): CoroutineScope { + override val coroutineContext: CoroutineContext = Dispatchers.Main + SupervisorJob() +} + +@Composable +fun DetailsScreen(id: String) { + val viewModel: DetailsViewModel = rememberOnRoute(key = id) { + DetailsViewModel(id) + .apply { doOnDestroy { cancel() } } + } } ``` -> Due to this [issue](https://github.com/JetBrains/compose-multiplatform/issues/2900), you will still need to provide this -> type `ListScreen:class` manually for now. -> Once resolved, you will be able to use the `inline` `refied` (and nicer) signature -> ```kotlin -> val list: List = rememberOnRoute { _ -> List() } -> ``` -{style="warning"} ### Integrating with Decompose Components @@ -46,8 +58,8 @@ class ListComponent(context: RouterContext): ComponentContext by context @Composable fun MyScreen() { - val listComponent: ListComponent = rememberOnRoute(ListComponent::class) { context -> - ListComponent(context) + val component: ListComponent = rememberOnRoute { // this: RouterContext + ListComponent(this) } } ``` @@ -64,7 +76,10 @@ class ListViewModel: ViewModel() @Composable fun ListScreen() { - val viewModel: ListViewModel = rememberOnRoute(type = ListViewModel::class) { ListViewModel() } + val viewModel: ListViewModel = rememberOnRoute { + ListViewModel() + .apply { doOnDestroy { viewModelScope.cancel() } } + } } ``` {collapsible="true" collapsed-title="class ListViewModel: androidx.lifecycle.ViewModel()"} @@ -86,7 +101,7 @@ Within your **State Holder**, you can derive the initial state by using `RouterC Make sure to point the supplier lambda back to your state flow so that it knows where to grab the latest state from to save ```kotlin -class List(context: RouterContext) { +class ListViewModel(context: RouterContext) { private val initialState: ListState = context.state(ListState()) { states.value } private val _state: MutableStateFlow = MutableStateFlow(initialState) val states: StateFlow = _state @@ -98,7 +113,7 @@ class List(context: RouterContext) { For [Molecule](https://github.com/cashapp/molecule), initial state can be provided to your `moleculeFlow` in conjunction with `stateIn` ```kotlin -class List(context: RouterContext) { +class ListViewModel(context: RouterContext) { private val initialState: ListState = context.state(ListState()) { states.value } val states: StateFlow = moleculeFlow(ContextClock) { ListPresenter() } .stateIn(this, SharingStarted.Lazily, initialState) diff --git a/docs/topics/overview.md b/docs/topics/overview.md index 2700afa..10bec53 100644 --- a/docs/topics/overview.md +++ b/docs/topics/overview.md @@ -25,8 +25,8 @@ A Compose-multiplatform navigation library that leverage [Decompose](https://git ```kotlin // Declare your screen configurations for type-safety @Serializable -sealed class Screen: Parcelable { - object List : Screen() +sealed class Screen : Parcelable { + data object List : Screen() data class Details(val detail: String) : Screen() } @@ -40,9 +40,9 @@ 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) } } @@ -54,16 +54,24 @@ fun DetailsScreen(detail: String) { // 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 instance: DetailInstance = rememberOnRoute(key = detail) { context -> DetailInstance(context, detail) } - - val state: DetailState by instance.states.collectAsState() + 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) } -// If you want your state to survive process death ☠️ derive your initial state from [SavedStateHandle] -class DetailInstance(context: RouterContext, detail: String) : InstanceKeeper.Instance { +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() + + // 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 = stateFlow } ``` diff --git a/docs/topics/using-pages-navigation.md b/docs/topics/using-pages-navigation.md index f5381b7..0239bba 100644 --- a/docs/topics/using-pages-navigation.md +++ b/docs/topics/using-pages-navigation.md @@ -14,18 +14,10 @@ enum class PagesScreens { Page1, Page2, Page3 } ````kotlin @Composable fun PagesScreen() { - val router: Router = rememberRouter(PagesScreens::class) { pagesOf(Page1, Page2, Page3) } + val router: Router = rememberRouter { pagesOf(Page1, Page2, Page3) } } ```` -> Due to this [issue](https://github.com/JetBrains/compose-multiplatform/issues/2900), you will still need to provide this -> type `PagesScreens:class` manually for now. -> Once resolved, you will be able to use the `inline` `refied` (and nicer) signature -> ```kotlin -> val router: Router = rememberRouter { pagesOf(Page1, Page2, Page3) } -> ``` -{style="warning"} - ## Consuming the state from the router Use `RoutedContent` to consume the state from the router. @@ -33,7 +25,7 @@ Use `RoutedContent` to consume the state from the router. ```kotlin @Composable fun PagesScreen() { - val router: Router = rememberRouter(PagesScreens::class) { pagesOf(Page1, Page2, Page3) } + val router: Router = rememberRouter { pagesOf(Page1, Page2, Page3) } RoutedContent(router = router) { screen: PagesScreens -> when (screen) { @@ -50,7 +42,7 @@ fun PagesScreen() { Decompose-router exposes the same Decompose page navigator extension [functions](https://arkivanov.github.io/Decompose/navigation/stack/navigation/#stacknavigator-extension-functions) ```kotlin -val router: Router = rememberRouter(PagesScreens::class) { pagesOf(Page1, Page2, Page3) } +val router: Router = rememberRouter { pagesOf(Page1, Page2, Page3) } // To go to second page Button(onClick = { number -> router.select(1) }) @@ -63,4 +55,4 @@ Button(onClick = { router.selectFirst() }) Decompose API Documentation for pages - \ No newline at end of file + diff --git a/docs/topics/using-slot-navigation.md b/docs/topics/using-slot-navigation.md index 87f73a1..9a5923f 100644 --- a/docs/topics/using-slot-navigation.md +++ b/docs/topics/using-slot-navigation.md @@ -17,18 +17,10 @@ Define your navigation model, (as already covered in [model-driven navigation se ````kotlin @Composable fun SlotScreen() { - val router: Router = rememberRouter(SlotScreens::class) { null } + val router: Router = rememberRouter { null } } ```` -> Due to this [issue](https://github.com/JetBrains/compose-multiplatform/issues/2900), you will still need to provide this -> type `SlotScreens:class` manually for now. -> Once resolved, you will be able to use the `inline` `refied` (and nicer) signature -> ```kotlin -> val router: Router = rememberRouter { null } -> ``` -{style="warning"} - ## Using the router to open up a dialog Use `RoutedContent` to use the router to open a dialog. @@ -36,14 +28,14 @@ Use `RoutedContent` to use the router to open a dialog. ```kotlin @Composable fun SlotScreen() { - val router: Router = rememberRouter(SlotScreens::class) { null } + val router: Router = rememberRouter { null } RoutedContent(router) { _ -> AlertDialog( onDismissRequest = { dialogRouter.dismiss() }, - ){ + ) { // Dialog content here - ) + } } } ``` @@ -65,4 +57,4 @@ Button( Decompose API Documentation for slots - \ No newline at end of file + diff --git a/docs/topics/using-stack-navigation.md b/docs/topics/using-stack-navigation.md index ed60d55..819872c 100644 --- a/docs/topics/using-stack-navigation.md +++ b/docs/topics/using-stack-navigation.md @@ -19,20 +19,12 @@ sealed class StackScreens { ````kotlin @Composable fun StackScreen() { - val router: Router = rememberRouter(StackScreens::class) { + val router: Router = rememberRouter { listOf(StackScreens.List) // Root screen to be set here } } ```` -> Due to this [issue](https://github.com/JetBrains/compose-multiplatform/issues/2900), you will still need to provide this -> type `StackScreens:class` manually for now. -> Once resolved, you will be able to use the `inline` `refied` (and nicer) signature -> ```kotlin -> val router: Router = rememberRouter { listOf(StackScreens.List) } -> ``` -{style="warning"} - ## Consuming the state from the router Use `RoutedContent` to consume the state from the router. @@ -40,7 +32,7 @@ Use `RoutedContent` to consume the state from the router. ```kotlin @Composable fun StackScreen() { - val router: Router = rememberRouter(StackScreens::class) { listOf(StackScreens.List) } + val router: Router = rememberRouter { listOf(StackScreens.List) } RoutedContent(router = router) { screen: StackScreens -> when (screen) { @@ -57,7 +49,7 @@ Decompose-router exposes the same Decompose stack navigator extension [functions ```kotlin -val router: Router = rememberRouter(StackScreens::class) { listOf(StackScreens.List) } +val router: Router = rememberRouter { listOf(StackScreens.List) } // To go to details screen Button(onClick = { number -> router.push(StackScreens.Details(number)) }) @@ -96,4 +88,4 @@ RoutedContent( Decompose API Documentation - \ No newline at end of file +