From bcf85c0674184921536f9322bc851fc83662a29b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B0=D0=BD=D0=B8=D0=BB=20=D0=AE=D0=B4=D0=BE=D0=B2?= Date: Sun, 1 Jun 2025 05:00:36 +0300 Subject: [PATCH 1/2] Implement `Router` for `ItemsNavigation` --- .../items/ItemsRouterLifecycleController.kt | 43 +++++++++++++ .../decompose/router/items/RoutedItems.kt | 63 +++++++++++++++++++ .../xxfast/decompose/router/items/Router.kt | 60 ++++++++++++++++++ gradle/libs.versions.toml | 2 +- 4 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 decompose-router/src/commonMain/kotlin/io/github/xxfast/decompose/router/items/ItemsRouterLifecycleController.kt create mode 100644 decompose-router/src/commonMain/kotlin/io/github/xxfast/decompose/router/items/RoutedItems.kt create mode 100644 decompose-router/src/commonMain/kotlin/io/github/xxfast/decompose/router/items/Router.kt diff --git a/decompose-router/src/commonMain/kotlin/io/github/xxfast/decompose/router/items/ItemsRouterLifecycleController.kt b/decompose-router/src/commonMain/kotlin/io/github/xxfast/decompose/router/items/ItemsRouterLifecycleController.kt new file mode 100644 index 0000000..927698f --- /dev/null +++ b/decompose-router/src/commonMain/kotlin/io/github/xxfast/decompose/router/items/ItemsRouterLifecycleController.kt @@ -0,0 +1,43 @@ +package io.github.xxfast.decompose.router.items + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.extensions.compose.lazyitems.ChildItemsLifecycleController + +@OptIn(ExperimentalDecomposeApi::class) +@Composable +fun ItemsRouterLifecycleController( + router: Router, + lazyListState: LazyListState, + itemIndexConverter: (Int) -> Int, + forwardPreloadCount: Int = 0, + backwardPreloadCount: Int = 0, +) { + ChildItemsLifecycleController( + items = router.lazyItems, + lazyListState = lazyListState, + itemIndexConverter = itemIndexConverter, + forwardPreloadCount = forwardPreloadCount, + backwardPreloadCount = backwardPreloadCount + ) +} + +@OptIn(ExperimentalDecomposeApi::class) +@Composable +fun ItemsRouterLifecycleController( + router: Router, + lazyGridState: LazyGridState, + itemIndexConverter: (Int) -> Int, + forwardPreloadCount: Int = 0, + backwardPreloadCount: Int = 0, +) { + ChildItemsLifecycleController( + items = router.lazyItems, + lazyGridState = lazyGridState, + itemIndexConverter = itemIndexConverter, + forwardPreloadCount = forwardPreloadCount, + backwardPreloadCount = backwardPreloadCount + ) +} \ No newline at end of file diff --git a/decompose-router/src/commonMain/kotlin/io/github/xxfast/decompose/router/items/RoutedItems.kt b/decompose-router/src/commonMain/kotlin/io/github/xxfast/decompose/router/items/RoutedItems.kt new file mode 100644 index 0000000..380b1c6 --- /dev/null +++ b/decompose-router/src/commonMain/kotlin/io/github/xxfast/decompose/router/items/RoutedItems.kt @@ -0,0 +1,63 @@ +package io.github.xxfast.decompose.router.items + +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridItemScope +import androidx.compose.foundation.lazy.grid.LazyGridItemSpanScope +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.router.items.ChildItems +import io.github.xxfast.decompose.router.LocalRouterContext +import io.github.xxfast.decompose.router.RouterContext + +@OptIn(ExperimentalDecomposeApi::class) +inline fun LazyListScope.items( + router: Router, + noinline key: ((item: C) -> Any)? = null, + noinline contentType: (item: C) -> Any? = { null }, + crossinline itemContent: @Composable LazyItemScope.(item: C) -> Unit +) { + val childItems: ChildItems by router.items + + items( + count = childItems.items.size, + key = if (key != null) { index: Int -> key(childItems.items[index]) } else null, + contentType = { index: Int -> contentType(childItems.items[index]) } + ) { + val item: C = childItems.items[it] + val context: RouterContext = router.getContext(item) + + CompositionLocalProvider(LocalRouterContext provides context) { + itemContent(item) + } + } +} + +@OptIn(ExperimentalDecomposeApi::class) +inline fun LazyGridScope.items( + router: Router, + noinline key: ((item: C) -> Any)? = null, + noinline span: (LazyGridItemSpanScope.(item: C) -> GridItemSpan)? = null, + noinline contentType: (item: C) -> Any? = { null }, + crossinline itemContent: @Composable LazyGridItemScope.(item: C) -> Unit +) { + val childItems: ChildItems by router.items + + items( + count = childItems.items.size, + key = if (key != null) { index: Int -> key(childItems.items[index]) } else null, + span = if (span != null) { index: Int -> span(childItems.items[index]) } else null, + contentType = { index: Int -> contentType(childItems.items[index]) } + ) { + val item: C = childItems.items[it] + val context: RouterContext = router.getContext(item) + + CompositionLocalProvider(LocalRouterContext provides context) { + itemContent(item) + } + } +} \ No newline at end of file diff --git a/decompose-router/src/commonMain/kotlin/io/github/xxfast/decompose/router/items/Router.kt b/decompose-router/src/commonMain/kotlin/io/github/xxfast/decompose/router/items/Router.kt new file mode 100644 index 0000000..263e9c7 --- /dev/null +++ b/decompose-router/src/commonMain/kotlin/io/github/xxfast/decompose/router/items/Router.kt @@ -0,0 +1,60 @@ +package io.github.xxfast.decompose.router.items + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.router.items.ChildItems +import com.arkivanov.decompose.router.items.Items +import com.arkivanov.decompose.router.items.ItemsNavigation +import com.arkivanov.decompose.router.items.LazyChildItems +import com.arkivanov.decompose.router.items.childItems +import com.arkivanov.essenty.lifecycle.Lifecycle +import io.github.xxfast.decompose.router.LocalRouterContext +import io.github.xxfast.decompose.router.RouterContext +import io.github.xxfast.decompose.router.asState +import io.github.xxfast.decompose.router.getOrCreate +import io.github.xxfast.decompose.router.key +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.serializerOrNull + +@OptIn(ExperimentalDecomposeApi::class) +class Router @PublishedApi internal constructor( + private val navigation: ItemsNavigation, + @PublishedApi internal val lazyItems: LazyChildItems, + private val lifecycle: Lifecycle +) : ItemsNavigation by navigation { + + val items: State> + get() = lazyItems.asState(lifecycle) + + fun getContext(configuration: C): RouterContext = lazyItems[configuration] + +} + +@OptIn(InternalSerializationApi::class, ExperimentalDecomposeApi::class) +@Composable +inline fun rememberRouter( + key: Any = C::class.key, + noinline initialItems: () -> List, +): Router { + val routerContext = LocalRouterContext.current + val routerKey = "$key.router" + + return remember(routerKey) { + routerContext.getOrCreate(key = routerKey) { + val navigation: ItemsNavigation = ItemsNavigation() + val childItems: LazyChildItems = routerContext + .childItems( + source = navigation, + serializer = C::class.serializerOrNull(), + initialItems = { Items(items = initialItems()) }, + key = routerKey, + childFactory = { _, childComponentContext -> RouterContext(childComponentContext) } + ) + + Router(navigation, childItems, routerContext.lifecycle) + } + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f80fd22..e51a9ed 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ androidx-activity ="1.7.2" androidx-fragment ="1.6.1" compose-multiplatform = "1.8.0" compose-test-rule = "1.5.1" -decompose = "3.4.0-alpha01" +decompose = "3.4.0-alpha02" dokka = "1.8.20" binary-compatibility-validator = "0.17.0" horologist = "0.5.7" From d90c1e1c16f8eb90691f8fee008574129076294b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B0=D0=BD=D0=B8=D0=BB=20=D0=AE=D0=B4=D0=BE=D0=B2?= Date: Sun, 1 Jun 2025 16:25:35 +0300 Subject: [PATCH 2/2] Example for `ItemsNavigation` --- .../decompose/router/screens/HomeScreen.kt | 8 +- .../router/screens/HomeStateModels.kt | 1 + .../decompose/router/screens/TestTags.kt | 1 + .../router/screens/items/ItemsScreen.kt | 143 ++++++++++++++++++ .../screens/items/item/ItemStateModels.kt | 5 + .../screens/items/item/ItemViewModel.kt | 32 ++++ 6 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/items/ItemsScreen.kt create mode 100644 app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/items/item/ItemStateModels.kt create mode 100644 app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/items/item/ItemViewModel.kt diff --git a/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/HomeScreen.kt b/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/HomeScreen.kt index f07c1c3..06b2f82 100644 --- a/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/HomeScreen.kt +++ b/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/HomeScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.List import androidx.compose.material.icons.rounded.CropSquare import androidx.compose.material.icons.rounded.ImportContacts import androidx.compose.material.icons.rounded.Reorder @@ -25,13 +26,15 @@ import io.github.xxfast.decompose.router.pages.rememberRouter import io.github.xxfast.decompose.router.screens.HomeScreens.Pages import io.github.xxfast.decompose.router.screens.HomeScreens.Slot import io.github.xxfast.decompose.router.screens.HomeScreens.Stack +import io.github.xxfast.decompose.router.screens.HomeScreens.Items +import io.github.xxfast.decompose.router.screens.items.ItemsScreen import io.github.xxfast.decompose.router.screens.pages.PagesScreen import io.github.xxfast.decompose.router.screens.slot.SlotScreen import io.github.xxfast.decompose.router.screens.stack.StackScreen @Composable fun HomeScreen() { - val pager: Router = rememberRouter { pagesOf(Stack, Pages, Slot) } + val pager: Router = rememberRouter { pagesOf(Stack, Pages, Slot, Items) } Scaffold( bottomBar = { @@ -47,6 +50,7 @@ fun HomeScreen() { Stack -> Icons.Rounded.Reorder Pages -> Icons.Rounded.ImportContacts Slot -> Icons.Rounded.CropSquare + Items -> Icons.AutoMirrored.Rounded.List }, contentDescription = null, ) @@ -58,6 +62,7 @@ fun HomeScreen() { Stack -> BOTTOM_NAV_STACK Pages -> BOTTOM_NAV_PAGES Slot -> BOTTOM_NAV_SLOT + Items -> BOTTOM_NAV_ITEMS } ) ) @@ -84,6 +89,7 @@ fun HomeScreen() { Stack -> StackScreen() Pages -> PagesScreen() Slot -> SlotScreen() + Items -> ItemsScreen() } } } diff --git a/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/HomeStateModels.kt b/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/HomeStateModels.kt index 673bc81..420862d 100644 --- a/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/HomeStateModels.kt +++ b/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/HomeStateModels.kt @@ -4,4 +4,5 @@ enum class HomeScreens { Stack, Pages, Slot, + Items, } 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 1b8cdf2..b22d7c7 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 @@ -6,6 +6,7 @@ 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_NAV_ITEMS = "bottonNavItems" const val BOTTOM_SHEET = "bottomSheet" const val BUTTON_BOTTOM_SHEET = "btnBottomSheet" const val BUTTON_DIALOG = "btnDialog" diff --git a/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/items/ItemsScreen.kt b/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/items/ItemsScreen.kt new file mode 100644 index 0000000..3c15bba --- /dev/null +++ b/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/items/ItemsScreen.kt @@ -0,0 +1,143 @@ +package io.github.xxfast.decompose.router.screens.items + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Remove +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +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.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.router.items.setItems +import io.github.xxfast.decompose.router.items.ItemsRouterLifecycleController +import io.github.xxfast.decompose.router.items.Router +import io.github.xxfast.decompose.router.items.items +import io.github.xxfast.decompose.router.items.rememberRouter +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 +import io.github.xxfast.decompose.router.screens.TOOLBAR +import io.github.xxfast.decompose.router.screens.items.item.ItemViewModel +import io.github.xxfast.decompose.router.screens.items.item.ItemState + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalDecomposeApi::class) +@Composable +fun ItemsScreen() { + var lastIndex: Int by remember { mutableStateOf(10) } + val router: Router = rememberRouter { (1..lastIndex).toList() } + val listState: LazyListState = rememberLazyListState() + + Scaffold( + topBar = { + TopAppBar( + modifier = Modifier.testTag(TOOLBAR), + title = { + Text( + text = "Items", + modifier = Modifier.testTag(TITLE_BAR) + ) + }, + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = { + router.setItems { it + (++lastIndex) } + }, + content = { Icon(Icons.Rounded.Add, null) }, + modifier = Modifier.testTag(FAB_ADD) + ) + }, + contentWindowInsets = WindowInsets(0, 0, 0, 0) + ) { paddingValues -> + LazyColumn( + state = listState, + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + .testTag(LIST_TAG), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(16.dp), + ) { + items( + router = router, + key = { it } + ) { item -> + val itemComponent: ItemViewModel = rememberOnRoute(key = "item_$item") { + ItemViewModel(this) + } + + val state: ItemState by itemComponent.states.collectAsState() + + Card( + modifier = Modifier + .height(128.dp) + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .animateItem(), + ) { + Box( + modifier = Modifier.fillMaxSize() + ) { + IconButton( + onClick = { + router.setItems { it - item } + }, + modifier = Modifier.align(Alignment.TopEnd) + ) { + Icon( + imageVector = Icons.Rounded.Remove, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + } + + Text( + text = "#$item — ${state.tick}", + style = MaterialTheme.typography.displayMedium, + modifier = Modifier + .align(Alignment.Center) + .padding(8.dp) + ) + } + } + } + } + } + + ItemsRouterLifecycleController( + router = router, + lazyListState = listState, + itemIndexConverter = { it }, + forwardPreloadCount = 3, + backwardPreloadCount = 3 + ) +} \ No newline at end of file diff --git a/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/items/item/ItemStateModels.kt b/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/items/item/ItemStateModels.kt new file mode 100644 index 0000000..d8eb94d --- /dev/null +++ b/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/items/item/ItemStateModels.kt @@ -0,0 +1,5 @@ +package io.github.xxfast.decompose.router.screens.items.item + +import kotlinx.serialization.Serializable + +@Serializable data class ItemState(val tick: Int) \ No newline at end of file diff --git a/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/items/item/ItemViewModel.kt b/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/items/item/ItemViewModel.kt new file mode 100644 index 0000000..e1eaf63 --- /dev/null +++ b/app/src/commonMain/kotlin/io/github/xxfast/decompose/router/screens/items/item/ItemViewModel.kt @@ -0,0 +1,32 @@ +package io.github.xxfast.decompose.router.screens.items.item + +import io.github.xxfast.decompose.router.RouterContext +import io.github.xxfast.decompose.router.state +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration.Companion.seconds + +class ItemViewModel(context: RouterContext): CoroutineScope { + private val initialState: ItemState = context.state(ItemState(0)) { states.value } + private val _state: MutableStateFlow = MutableStateFlow(initialState) + val states: StateFlow = _state + + override val coroutineContext: CoroutineContext = Dispatchers.Main + + init { + launch { + while (isActive) { + delay(1.seconds) + val previous: ItemState = _state.value + val updated: ItemState = previous.copy(tick = previous.tick + 1) + _state.emit(updated) + } + } + } +}