diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index e3e47f1dc..8f2f57429 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,3 +1,4 @@ # These are supported funding model platforms +github: arkivanov custom: ["https://www.buymeacoffee.com/arkivanov", "https://btc.com/1DXjn9e6rmbVvac3TH8hG3LdLtoA1CUvsM", "https://etherscan.io/address/0xf027f5738f45676a54c15cf7753a0f66553947b9"] diff --git a/docs/component/back-button.md b/docs/component/back-button.md index ecb8e1166..9f406db19 100644 --- a/docs/component/back-button.md +++ b/docs/component/back-button.md @@ -35,6 +35,20 @@ class SomeComponent( } ``` +### Callback order + +By default, registered callbacks are checked in reverse order, the last registered enabled callback is called first. Various navigation models may also register back button callbacks, e.g. `Child Stack` uses `BackHandler` to automatically pop the stack on back button press. If you want your callback to be called first, make sure to register it as later as possible. Similarly, if you want your callback to be called last, make sure to register it as early as possible. + +Since Essenty version `1.2.0-alpha`, it is also possible to specify a priority for your back callback. + +```kotlin +// This will make sure your callback is always called first +private val backCallback = BackCallback(priority = Int.MAX_VALUE) { ... } + +// This will make sure your callback is always called last +private val backCallback = BackCallback(priority = Int.MIN_VALUE) { ... } +``` + ## Predictive Back Gesture Decompose experimentally supports the new [Android Predictive Back Gesture](https://developer.android.com/guide/navigation/custom-back/predictive-back-gesture), not only on Android. The UI part is covered by Compose extensions, please see the [related docs](../../extensions/compose#predictive-back-gesture). diff --git a/docs/component/custom-component-context.md b/docs/component/custom-component-context.md index 0b7e8669a..7c4f1ac41 100644 --- a/docs/component/custom-component-context.md +++ b/docs/component/custom-component-context.md @@ -22,6 +22,18 @@ class DefaultAppComponentContext( } ``` +## Custom child ComponentContext + +The default [ComponentContext#childContext](../child-components/#adding-a-child-component-manually) extension function returns the default `ComponentContext`. In order to create custom child `ComponentContext`, a special extension function is required. + +```kotlin +fun AppComponentContext.childAppContext(key: String, lifecycle: Lifecycle? = null): AppComponentContext = + DefaultAppComponentContext( + componentContext = childContext(key = key, lifecycle = lifecycle), + // Supply additional dependencies here + ) +``` + ## Navigation with custom ComponentContext - [Using Child Stack](../navigation/stack/component-context.md) diff --git a/docs/component/overview.md b/docs/component/overview.md index 80079f7f8..19f305319 100644 --- a/docs/component/overview.md +++ b/docs/component/overview.md @@ -151,7 +151,7 @@ Using `Value` is not mandatory, you can use any other state holders, e.g. [State If you are using Jetpack/JetBrains Compose, `Value` can be observed in Composable functions using one of the Compose [extension modules](/Decompose/extensions/compose/). !!!warning - `Value` is not thread-safe, it should be accessed only from the main thread. + Even though both `Value` and `MutableValue` are thread-safe, it's recommended to subscribe and update it only on the main thread. ### Why not StateFlow? @@ -169,7 +169,7 @@ class Counter { val state: Value = _state fun increment() { - _state.reduce { it.copy(count = it.count + 1) } + _state.update { it.copy(count = it.count + 1) } } data class State(val count: Int = 0) @@ -198,26 +198,27 @@ fun CounterUi(counter: Counter) { ```swift struct CounterView: View { private let counter: Counter - @ObservedObject - private var state: ObservableValue + + @StateValue + private var state: CounterState init(_ counter: Counter) { self.counter = counter - self.state = ObservableValue(counter.state) + _state = StateValue(counter.state) } var body: some View { VStack(spacing: 8) { - Text(self.state.value.text) - Button(action: self.counter.increment, label: { Text("Increment") }) + Text(state.value.text) + Button(action: counter.increment, label: { Text("Increment") }) } } } ``` -#### What is ObservableValue? +#### What is StateValue -[ObservableValue](https://github.com/arkivanov/Decompose/blob/master/sample/app-ios/app-ios/DecomposeHelpers/ObservableValue.swift) is a wrapper around `Value` that makes it compatible with SwiftUI. It is a simple class that conforms to `ObservableObject` protocol. Unfortunately it [does not look possible](https://github.com/arkivanov/Decompose/issues/206) to publish utils for SwiftUI as a library or framework, so it has to be copied to your project. +[StateValue](https://github.com/arkivanov/Decompose/blob/master/sample/app-ios/app-ios/DecomposeHelpers/StateValue.swift) is a property wrapper for `Value` that makes it observable in SwiftUI. Unfortunately it [does not look possible](https://github.com/arkivanov/Decompose/issues/206) to publish utils for SwiftUI as a library or framework, so it has to be copied in your project. #### More Swift utilities diff --git a/docs/extensions/compose.md b/docs/extensions/compose.md index 09c75dfcc..c78dd5b3c 100644 --- a/docs/extensions/compose.md +++ b/docs/extensions/compose.md @@ -38,6 +38,14 @@ Extensions for JetBrains Compose are provided by the `extensions-compose-jetbrai implementation("com.arkivanov.decompose:extensions-compose-jetbrains:") ``` +#### ProGuard rules for Compose for Desktop (JVM) + +If you support Compose for Desktop, you will need to add the following rule for ProGuard, so that the app works correctly in release mode. See [Minification & obfuscation](https://github.com/JetBrains/compose-multiplatform/tree/master/tutorials/Native_distributions_and_local_execution#minification--obfuscation) section in Compose docs for more information. + +``` +-keep class com.arkivanov.decompose.extensions.compose.jetbrains.mainthread.SwingMainThreadChecker +``` + ## Content As mentioned above both modules provide similar functionality. Most of the links in this document refer to the Jetpack module, however there usually a mirror in the JetBrains module. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index efcb6ec2c..e80219333 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -24,6 +24,9 @@ The main functionality is provided by the `decompose` module. It contains some c Some functionality is actually provided by [Essenty](https://github.com/arkivanov/Essenty) library. Essenty is implemented by the same author and provides very basic things like `Lifecycle`, `StateKeeper`, etc. Most important Essenty modules are added to the `decompose` module as `api` dependency, so you don't have to add them manually to your project. Please familiarise yourself with Essenty library. +!!! note + If you are targetting Android, make sure you applied the [kotlin-parcelize](https://developer.android.com/kotlin/parcelize) Gradle plugin. + ## Extensions for Jetpack/JetBrains Compose The Compose UI is currently published in two separate variants: diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index a9893cc7c..8e0c17d73 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -70,7 +70,9 @@ class DefaultListComponent( } ``` -Observing `Value` in Jetpack Compose is easy, just use `subscribeAsState` extension function. +### Observing Value in Jetpack Compose + +Observing `Value` in Jetpack Compose is easy, just use the `subscribeAsState` extension function. ```kotlin @Composable @@ -90,6 +92,34 @@ fun ListContent(component: ListComponent, modifier: Modifier = Modifier) { } ``` +### Observing Value in SwiftUI + +```swift +struct DetailsView: View { + private let list: ListComponent + + @StateValue + private var model: ListComponentModel + + init(_ list: ListComponent) { + self.list = list + _model = StateValue(list.model) + } + + var body: some View { + List(model.items, ...) { item in + // Display the item + } + } +} +``` + +#### What is StateValue + +[StateValue](https://github.com/arkivanov/Decompose/blob/master/sample/app-ios/app-ios/DecomposeHelpers/StateValue.swift) is a property wrapper for `Value` that makes it observable in SwiftUI. Unfortunately it [does not look possible](https://github.com/arkivanov/Decompose/issues/206) to publish utils for SwiftUI as a library or framework, so it has to be copied in your project. + +### Observing Value in other UI Frameworks + Please refer to the [docs](/Decompose/component/overview/) for information about other platforms and UI frameworks. ### Using Reaktive or coroutines @@ -114,6 +144,9 @@ interface RootComponent { val stack: Value> + // It's possible to pop multiple screens at a time on iOS + fun onBackClicked(toIndex: Int) + // Defines all possible child components sealed class Child { class ListChild(val component: ListComponent) : Child() @@ -127,7 +160,7 @@ class DefaultRootComponent( private val navigation = StackNavigation() - private val _stack = + override val stack: Value> = childStack( source = navigation, initialConfiguration = Config.List, // The initial child component is List @@ -135,8 +168,6 @@ class DefaultRootComponent( childFactory = ::child, ) - override val stack: Value> = _stack - private fun child(config: Config, componentContext: ComponentContext): RootComponent.Child = when (config) { is Config.List -> ListChild(listComponent(componentContext)) @@ -157,6 +188,10 @@ class DefaultRootComponent( item = config.item, // Supply arguments from the configuration onFinished = navigation::pop, // Pop the details component ) + + override fun onBackClicked(toIndex: Int) { + navigation.popTo(index = toIndex) + } @Parcelize // The `kotlin-parcelize` plugin must be applied if you are targeting Android private sealed interface Config : Parcelable { @@ -190,29 +225,36 @@ fun RootContent(component: RootComponent, modifier: Modifier = Modifier) { struct RootView: View { private let root: RootComponent - @ObservedObject - private var childStack: ObservableValue> - - private var activeChild: RootComponentChild { childStack.value.active.instance } - init(_ root: RootComponent) { self.root = root - childStack = ObservableValue(root.childStack) } var body: some View { - switch activeChild { - case let child as RootComponentChild.ListChild: ListView(child.component) - case let child as RootComponentChild.DetailsChild: DetailsView(child.component) - default: EmptyView() - } + StackView( + stackValue: StateValue(root.stack), + getTitle: { + switch $0 { + case is RootComponentChild.ListChild: return "List" + case is RootComponentChild.DetailsChild: return "Details" + default: return "" + } + }, + onBack: root.onBackClicked, + childContent: { + switch $0 { + case let child as RootComponentChild.ListChild: ListView(child.component) + case let child as RootComponentChild.DetailsChild: DetailsView(child.component) + default: EmptyView() + } + } + ) } } ``` -#### What is ObservableValue? +#### What is StackView? -[ObservableValue](https://github.com/arkivanov/Decompose/blob/master/sample/app-ios/app-ios/DecomposeHelpers/ObservableValue.swift) is a wrapper around `Value` that makes it compatible with SwiftUI. It is a simple class that conforms to `ObservableObject` protocol. Unfortunately it [does not look possible](https://github.com/arkivanov/Decompose/issues/206) to publish utils for SwiftUI as a library or framework, so it has to be copied to your project. +[StackView](https://github.com/arkivanov/Decompose/blob/master/sample/app-ios/app-ios/DecomposeHelpers/StackView.swift) is a view that displays `Child Stack` using the native SwiftUI navigation and providing the native UX. For the same reason, it has to be copied in your project. ### Child Stack with other UI Frameworks @@ -222,7 +264,7 @@ Please refer to [samples](/Decompose/samples/) for integrations with other UI fr ### Android with Jetpack Compose -Use `defaultComponentContext` extension function to create the root `ComponentContext` in an `Activity`. +Use `defaultComponentContext` extension function to create the root `ComponentContext` in an `Activity` or a `Fragment`. ```kotlin class MainActivity : AppCompatActivity() { diff --git a/docs/media/SampleCardsAndroid.gif b/docs/media/SampleCardsAndroid.gif new file mode 100644 index 000000000..cdfe64bc5 Binary files /dev/null and b/docs/media/SampleCardsAndroid.gif differ diff --git a/docs/samples.md b/docs/samples.md index 90b103046..8bfe569d9 100644 --- a/docs/samples.md +++ b/docs/samples.md @@ -17,11 +17,13 @@ Content: * [shared](https://github.com/arkivanov/Decompose/tree/master/sample/shared/shared) - this is a shared module that contains the following components: * [Root](https://github.com/arkivanov/Decompose/tree/master/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/root) - the root (top-most) component, it displays the bottom navigation bar and the currently selected tab. - * [Counters](https://github.com/arkivanov/Decompose/tree/master/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/counters) - the Counters tab, contains a stack of `Counter` component. - * [Counter](https://github.com/arkivanov/Decompose/tree/master/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/counters/counter) - the `Counter` component, it just increments the counter every 250 ms. It starts counting once created and stops when destroyed. So `Counter` continues counting while in the back stack, unless recreated. It uses the `InstanceKeeper`, so counting continues after Android configuration changes. The `StateKeeper` is used to preserve the state when the process is recreated on Android. - * [MultiPane](https://github.com/arkivanov/Decompose/tree/master/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/multipane) - the Multi-Pane tab, it displays `List` and `Details` components either in a stack (one on top of another) or side by side. **Please note that this sample is for advanced single-pane/multi-pane navigation and layout, for generic master-detail navigation please refer to the Sample Todo List App described below.** - * [ArticleList](https://github.com/arkivanov/Decompose/tree/master/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/multipane/list) - displays a random list of articles. Clicking on an item triggers the `ArticleDetails` component. - * [ArticleDetails](https://github.com/arkivanov/Decompose/tree/master/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/multipane/details) - displays the content of the selected article. + * [Counters](https://github.com/arkivanov/Decompose/tree/master/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/counters) - the Counters tab, contains a stack of `CounterComponent`. + * [Counter](https://github.com/arkivanov/Decompose/tree/master/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/counters/counter) - contains `CounterComponent`, it just increments the counter every 250 ms. It starts counting once created and stops when destroyed. So `CounterComponent` continues counting while in the back stack, unless recreated. It uses the `InstanceKeeper`, so counting continues after Android configuration changes. The `StateKeeper` is used to preserve the state when the process is recreated on Android. + * [Cards](https://github.com/arkivanov/Decompose/tree/master/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/cards) - the (draggable) Cards tab, contains a stack of [Card] components that can be dragged and thrown to the back of the stack. The top component is resumed and running, and components in the back stack are stopped. This sample demonstrates how the navigation can be controlled by gestures. + * [Card](https://github.com/arkivanov/Decompose/tree/master/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/cards/card) - contains `CardComponent` - a draggable card with some text information. + * [MultiPane](https://github.com/arkivanov/Decompose/tree/master/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/multipane) - the Multi-Pane tab, it displays `ArticleListComponent` and `ArticleDetailsComponent` components either in a stack (one on top of another) or side by side. **Please note that this sample is for advanced single-pane/multi-pane navigation and layout, for generic master-detail navigation please refer to the Sample Todo List App described below.** + * [ArticleListComponent](https://github.com/arkivanov/Decompose/tree/master/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/multipane/list) - displays a random list of articles. Clicking on an item triggers the `ArticleDetails` component. + * [ArticleDetailsComponent](https://github.com/arkivanov/Decompose/tree/master/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/multipane/details) - displays the content of the selected article. * [DynamicFeatures](https://github.com/arkivanov/Decompose/tree/master/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/dynamicfeatures) - the Dynamic Features tab, it demonstrates the usage of [Play Feature Delivery](https://developer.android.com/guide/playcore/feature-delivery) on Android, while using classing integration on other platforms. There are two simple feature components - `Feature1` and `Feature2` - they are located in separate modules described below. * [DynamicFeature](https://github.com/arkivanov/Decompose/tree/master/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/dynamicfeatures/dynamicfeature) - a helper component responsible for loading dynamic feature components. * [compose](https://github.com/arkivanov/Decompose/tree/master/sample/shared/compose) - this module contains Jetpack Compose UI. @@ -49,6 +51,7 @@ Content: ### Counters Screenshots + diff --git a/sample/app-ios/app-ios/RootView.swift b/sample/app-ios/app-ios/RootView.swift index 4dc4b0bb3..3054ae4fd 100644 --- a/sample/app-ios/app-ios/RootView.swift +++ b/sample/app-ios/app-ios/RootView.swift @@ -82,6 +82,7 @@ class PreviewRootComponent : RootComponent { simpleChildStack(RootComponentChild.CountersChild(component: PreviewCountersComponent())) func onCountersTabClicked() {} + func onCardsTabClicked() {} func onMultiPaneTabClicked() {} func onDynamicFeaturesTabClicked() {} func onCustomNavigationTabClicked() {} diff --git a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/Icons.kt b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/Icons.kt new file mode 100644 index 000000000..5b92604b4 --- /dev/null +++ b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/Icons.kt @@ -0,0 +1,44 @@ +package com.arkivanov.sample.shared + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.materialIcon +import androidx.compose.material.icons.materialPath +import androidx.compose.ui.graphics.vector.ImageVector + +// Copied from material-icons-extended +internal val Icons.Filled.SwipeUp: ImageVector by lazy { + materialIcon(name = "Filled.SwipeUp") { + materialPath { + moveTo(2.06f, 5.56f) + lineTo(1.0f, 4.5f) + lineTo(4.5f, 1.0f) + lineTo(8.0f, 4.5f) + lineTo(6.94f, 5.56f) + lineTo(5.32f, 3.94f) + curveTo(5.11f, 4.76f, 5.0f, 5.62f, 5.0f, 6.5f) + curveToRelative(0.0f, 2.42f, 0.82f, 4.65f, 2.2f, 6.43f) + lineTo(6.13f, 14.0f) + curveTo(4.49f, 11.95f, 3.5f, 9.34f, 3.5f, 6.5f) + curveToRelative(0.0f, -0.92f, 0.1f, -1.82f, 0.3f, -2.68f) + lineTo(2.06f, 5.56f) + close() + moveTo(13.85f, 11.62f) + lineToRelative(-2.68f, -5.37f) + curveToRelative(-0.37f, -0.74f, -1.27f, -1.04f, -2.01f, -0.67f) + curveTo(8.41f, 5.96f, 8.11f, 6.86f, 8.48f, 7.6f) + lineToRelative(4.81f, 9.6f) + lineTo(10.05f, 18.0f) + curveToRelative(-0.33f, 0.09f, -0.59f, 0.33f, -0.7f, 0.66f) + lineTo(9.0f, 19.78f) + lineToRelative(6.19f, 2.25f) + curveToRelative(0.5f, 0.17f, 1.28f, 0.02f, 1.75f, -0.22f) + lineToRelative(5.51f, -2.75f) + curveToRelative(0.89f, -0.45f, 1.32f, -1.48f, 1.0f, -2.42f) + lineToRelative(-1.43f, -4.27f) + curveToRelative(-0.27f, -0.82f, -1.04f, -1.37f, -1.9f, -1.37f) + horizontalLineToRelative(-4.56f) + curveToRelative(-0.31f, 0.0f, -0.62f, 0.07f, -0.89f, 0.21f) + lineTo(13.85f, 11.62f) + } + } +} diff --git a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/cards/CardsContent.kt b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/cards/CardsContent.kt new file mode 100644 index 000000000..51deb9524 --- /dev/null +++ b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/cards/CardsContent.kt @@ -0,0 +1,302 @@ +@file:Suppress("OPTIONAL_DECLARATION_USAGE_IN_NON_COMMON_SOURCE") // Workaround for KTIJ-22326 + +package com.arkivanov.sample.shared.cards + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animateOffsetAsState +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidthIn +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +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.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.round +import com.arkivanov.decompose.Child +import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState +import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import com.arkivanov.sample.shared.cards.card.CardComponent +import com.arkivanov.sample.shared.cards.card.CardContent +import com.arkivanov.sample.shared.cards.card.PreviewCardComponent +import com.arkivanov.sample.shared.utils.toPx + +@Composable +internal fun CardsContent(component: CardsComponent, modifier: Modifier = Modifier) { + val stack by component.stack.subscribeAsState() + + Box(modifier = modifier.fillMaxSize().padding(16.dp)) { + IconButton( + onClick = component::onRemoveClicked, + modifier = Modifier.align(Alignment.TopStart) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Remove", + ) + } + + IconButton( + onClick = component::onAddClicked, + modifier = Modifier.align(Alignment.TopEnd) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Add", + ) + } + + DraggableCards( + items = stack.items, + onSwiped = component::onCardSwiped, + modifier = Modifier.fillMaxSize(), + ) + } +} + +@OptIn(ExperimentalAnimationApi::class) +@Composable +private fun DraggableCards( + items: List>, + onSwiped: (index: Int) -> Unit, + modifier: Modifier = Modifier, +) { + var lastItems: List> by remember { + mutableStateOf( + items.map { (configuration, instance) -> + Item( + configuration = configuration, + instance = instance, + transitionState = MutableTransitionState(initialState = true), + ) + } + ) + } + + DisposableEffect(items) { + lastItems = lastItems.diff(items) + onDispose {} + } + + var layoutSize by remember { mutableStateOf(IntSize.Zero) } + + Box( + modifier = modifier.onPlaced { layoutSize = it.size }, + contentAlignment = Alignment.BottomCenter, + ) { + lastItems.forEachIndexed { index, (configuration, instance, transitionState) -> + key(configuration) { + val indexFromEnd = lastItems.lastIndex - index + + DraggableCard( + layoutSize = layoutSize, + offsetY = indexFromEnd * -16.dp.toPx(), + scale = 1F - indexFromEnd.toFloat() / 20F, + onSwiped = { onSwiped(index) }, + ) { + AnimatedVisibility( + visibleState = transitionState, + enter = fadeIn() + scaleIn(), + exit = fadeOut() + scaleOut(), + ) { + CardContent( + component = instance, + modifier = Modifier.fillMaxSize(), + ) + + DisposableEffect(Unit) { + onDispose { + lastItems = lastItems.filterNot { it.configuration == configuration } + } + } + } + } + } + } + } +} + +private fun List>.diff(items: List>): List> { + val configs = items.map(Child.Created<*, *>::configuration) + val missingItems = filterNot { it.configuration in configs } + missingItems.forEach { it.transitionState.targetState = false } + val lastTransitionStates = associateBy(keySelector = Item<*>::configuration, valueTransform = Item<*>::transitionState) + + return items.map { (configuration, instance) -> + Item( + configuration = configuration, + instance = instance, + transitionState = lastTransitionStates[configuration] + ?: MutableTransitionState(initialState = false).apply { targetState = true }, + ) + } + missingItems +} + +@Composable +internal fun DraggableCard( + layoutSize: IntSize, + offsetY: Float, + scale: Float, + onSwiped: () -> Unit, + content: @Composable () -> Unit, +) { + var cardPosition: Offset by remember { mutableStateOf(Offset.Zero) } + var cardSize: IntSize by remember { mutableStateOf(IntSize.Zero) } + val minOffsetX: Float = -cardPosition.x - cardSize.width + val maxOffsetX: Float = layoutSize.width - cardPosition.x + val maxOffsetY: Float = -cardPosition.y + + var mode by remember { mutableStateOf(Mode.IDLE) } + var startTouchPosition: Offset by remember { mutableStateOf(Offset.Zero) } + var dragTotalOffset: Offset by remember { mutableStateOf(Offset.Zero) } + var dragLastOffset: Offset by remember { mutableStateOf(Offset.Zero) } + val dragDistanceThreshold = 3.dp.toPx() + + val animatedOffset by animateOffsetAsState( + targetValue = when (mode) { + Mode.DRAG -> dragTotalOffset + Offset(x = 0F, y = offsetY) + + Mode.UP -> { + val (x1, y1) = dragTotalOffset + val x2 = x1 + dragLastOffset.x + val y2 = y1 + dragLastOffset.y + val upperOffsetX = ((maxOffsetY - y1) * (x2 - x1) / (y2 - y1) + x1).coerceIn(minOffsetX, maxOffsetX) + Offset(x = upperOffsetX, y = maxOffsetY) + } + + Mode.IDLE, + Mode.DOWN -> Offset(x = 0F, y = offsetY) + }, + animationSpec = if (mode == Mode.DRAG) snap() else tween() + ) + + val animatedScale by animateFloatAsState(targetValue = scale, animationSpec = tween()) + + DisposableEffect(animatedOffset, mode, offsetY) { + if ((mode == Mode.UP) && (animatedOffset.y == maxOffsetY)) { + onSwiped() + mode = Mode.DOWN + } else if ((mode == Mode.DOWN) && (animatedOffset.y == offsetY)) { + mode = Mode.IDLE + } + + onDispose {} + } + + Box( + modifier = Modifier + .onPlaced { + cardPosition = it.positionInParent() + cardSize = it.size + } + .requiredWidthIn(max = 256.dp) + .offset { animatedOffset.round() } + .aspectRatio(ratio = 1.5882353F) + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { position -> + startTouchPosition = position + dragTotalOffset = Offset.Zero + mode = Mode.DRAG + }, + onDragEnd = { mode = if (dragLastOffset.getDistance() > dragDistanceThreshold) Mode.UP else Mode.DOWN }, + onDrag = { change, dragAmount -> + change.consume() + dragTotalOffset += dragAmount + dragLastOffset = dragAmount + }, + ) + } + .scale(animatedScale) + .graphicsLayer { + if (mode == Mode.IDLE) { + return@graphicsLayer + } + + transformOrigin = + TransformOrigin( + pivotFractionX = startTouchPosition.x / size.width, + pivotFractionY = startTouchPosition.y / size.height, + ) + + val verticalFactor = (animatedOffset.y - offsetY) / (maxOffsetY - offsetY) + val horizontalFactor = transformOrigin.pivotFractionX * 2F - 1F + rotationZ = verticalFactor * horizontalFactor * -30F + } + ) { + content() + } +} + +private enum class Mode { + IDLE, DRAG, UP, DOWN +} + +private data class Item( + val configuration: Any, + val instance: T, + val transitionState: MutableTransitionState, +) + +@Preview +@Composable +internal fun CardsContentPreview() { + CardsContent(PreviewCardsComponent()) +} + +internal class PreviewCardsComponent : CardsComponent { + override val stack: Value> = + MutableValue( + ChildStack( + active = Child.Created( + configuration = 1, + instance = PreviewCardComponent(color = 0xFFFF0000), + ), + backStack = listOf( + Child.Created( + configuration = 2, + instance = PreviewCardComponent(color = 0xFF0000FF), + ) + ), + ) + ) + + override fun onCardSwiped(index: Int) {} + override fun onAddClicked() {} + override fun onRemoveClicked() {} +} + diff --git a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/cards/card/CardContent.kt b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/cards/card/CardContent.kt new file mode 100644 index 000000000..dc8863565 --- /dev/null +++ b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/cards/card/CardContent.kt @@ -0,0 +1,90 @@ +@file:Suppress("OPTIONAL_DECLARATION_USAGE_IN_NON_COMMON_SOURCE") // Workaround for KTIJ-22326 + +package com.arkivanov.sample.shared.cards.card + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import com.arkivanov.sample.shared.cards.card.CardComponent.Model + +@Composable +internal fun CardContent(component: CardComponent, modifier: Modifier = Modifier) { + val model by component.model.subscribeAsState() + + Column( + modifier = modifier + .shadow(elevation = 4.dp, shape = RoundedCornerShape(size = 16.dp), clip = true) + .background(Color(model.color)) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(space = 16.dp, alignment = Alignment.CenterVertically), + ) { + Title(text = model.title) + Row(text = model.status) + Row(text = model.text) + } +} + +@Composable +private fun Row(text: String = "") { + Text( + text = text, + modifier = Modifier + .fillMaxWidth() + .background(Color.White.copy(alpha = 0.5F)) + .padding(4.dp), + style = MaterialTheme.typography.caption, + ) +} + +@Composable +private fun Title(text: String) { + Box( + modifier = Modifier + .size(32.dp) + .background(Color.White.copy(alpha = 0.5F)), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + style = MaterialTheme.typography.subtitle1, + ) + } +} + +@Preview +@Composable +internal fun CardContentPreview() { + CardContent(component = PreviewCardComponent()) +} + +internal class PreviewCardComponent( + color: Long = 0xFFFF0000, +) : CardComponent { + override val model: Value = + MutableValue( + Model( + color = color, + title = "1", + status = "Status: Resumed", + text = "Count: 10", + ) + ) +} diff --git a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/root/RootContent.kt b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/root/RootContent.kt index b32ab6b3a..0d499f8f2 100644 --- a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/root/RootContent.kt +++ b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/root/RootContent.kt @@ -1,3 +1,5 @@ +@file:Suppress("OPTIONAL_DECLARATION_USAGE_IN_NON_COMMON_SOURCE") // Workaround for KTIJ-22326 + package com.arkivanov.sample.shared.root import androidx.compose.desktop.ui.tooling.preview.Preview @@ -7,7 +9,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.BottomNavigation import androidx.compose.material.BottomNavigationItem import androidx.compose.material.Icon -import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.List @@ -20,6 +21,7 @@ import com.arkivanov.decompose.extensions.compose.jetbrains.stack.Children import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.Direction import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.StackAnimation import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.StackAnimator +import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.fade import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.isEnter import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.slide import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.stackAnimation @@ -27,6 +29,8 @@ import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState import com.arkivanov.decompose.router.stack.ChildStack import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.Value +import com.arkivanov.sample.shared.SwipeUp +import com.arkivanov.sample.shared.cards.CardsContent import com.arkivanov.sample.shared.counters.CountersContent import com.arkivanov.sample.shared.counters.PreviewCountersComponent import com.arkivanov.sample.shared.customnavigation.CustomNavigationComponent @@ -34,6 +38,7 @@ import com.arkivanov.sample.shared.customnavigation.CustomNavigationContent import com.arkivanov.sample.shared.dynamicfeatures.DynamicFeaturesContent import com.arkivanov.sample.shared.multipane.MultiPaneContent import com.arkivanov.sample.shared.root.RootComponent.Child +import com.arkivanov.sample.shared.root.RootComponent.Child.CardsChild import com.arkivanov.sample.shared.root.RootComponent.Child.CountersChild import com.arkivanov.sample.shared.root.RootComponent.Child.CustomNavigationChild import com.arkivanov.sample.shared.root.RootComponent.Child.DynamicFeaturesChild @@ -48,10 +53,14 @@ fun RootContent(component: RootComponent, modifier: Modifier = Modifier) { Children( stack = childStack, modifier = Modifier.weight(weight = 1F), - animation = tabAnimation(), + + // Workaround for https://issuetracker.google.com/issues/270656235 + animation = stackAnimation(fade()), +// animation = tabAnimation(), ) { when (val child = it.instance) { is CountersChild -> CountersContent(component = child.component, modifier = Modifier.fillMaxSize()) + is CardsChild -> CardsContent(component = child.component, modifier = Modifier.fillMaxSize()) is MultiPaneChild -> MultiPaneContent(component = child.component, modifier = Modifier.fillMaxSize()) is DynamicFeaturesChild -> DynamicFeaturesContent(component = child.component, modifier = Modifier.fillMaxSize()) is CustomNavigationChild -> CustomNavigationContent(component = child.component, modifier.fillMaxSize()) @@ -68,7 +77,17 @@ fun RootContent(component: RootComponent, modifier: Modifier = Modifier) { contentDescription = "Counters", ) }, - label = { Text(text = "Counters", softWrap = false) }, + ) + + BottomNavigationItem( + selected = activeComponent is CardsChild, + onClick = component::onCardsTabClicked, + icon = { + Icon( + imageVector = Icons.Filled.SwipeUp, + contentDescription = "Cards", + ) + }, ) BottomNavigationItem( @@ -80,7 +99,6 @@ fun RootContent(component: RootComponent, modifier: Modifier = Modifier) { contentDescription = "Multi-Pane", ) }, - label = { Text(text = "Multi-Pane", softWrap = false) }, ) BottomNavigationItem( @@ -92,7 +110,6 @@ fun RootContent(component: RootComponent, modifier: Modifier = Modifier) { contentDescription = "Dynamic Features", ) }, - label = { Text(text = "Dyn Features", softWrap = false) }, ) BottomNavigationItem( @@ -104,7 +121,6 @@ fun RootContent(component: RootComponent, modifier: Modifier = Modifier) { contentDescription = "Custom Navigation", ) }, - label = { Text(text = "Custom Nav", softWrap = false) }, ) } } @@ -123,9 +139,10 @@ private val Child.index: Int get() = when (this) { is CountersChild -> 0 - is MultiPaneChild -> 1 - is DynamicFeaturesChild -> 2 - is CustomNavigationChild -> 3 + is CardsChild -> 1 + is MultiPaneChild -> 2 + is DynamicFeaturesChild -> 3 + is CustomNavigationChild -> 4 } private fun StackAnimator.flipSide(): StackAnimator = @@ -138,7 +155,6 @@ private fun StackAnimator.flipSide(): StackAnimator = ) } -@Suppress("OPT_IN_USAGE") private fun Direction.flipSide(): Direction = when (this) { Direction.ENTER_FRONT -> Direction.ENTER_BACK @@ -163,6 +179,7 @@ internal class PreviewRootComponent : RootComponent { ) override fun onCountersTabClicked() {} + override fun onCardsTabClicked() {} override fun onMultiPaneTabClicked() {} override fun onDynamicFeaturesTabClicked() {} override fun onCustomNavigationTabClicked() {} diff --git a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/utils/Utils.kt b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/utils/Utils.kt new file mode 100644 index 000000000..c0812cb34 --- /dev/null +++ b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/utils/Utils.kt @@ -0,0 +1,9 @@ +package com.arkivanov.sample.shared.utils + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp + +@Composable +internal fun Dp.toPx(): Float = + with(LocalDensity.current) { toPx() } diff --git a/sample/shared/shared/src/androidMain/kotlin/com/arkivanov/sample/shared/root/RootView.kt b/sample/shared/shared/src/androidMain/kotlin/com/arkivanov/sample/shared/root/RootView.kt index 7ba18e801..aabf1d599 100644 --- a/sample/shared/shared/src/androidMain/kotlin/com/arkivanov/sample/shared/root/RootView.kt +++ b/sample/shared/shared/src/androidMain/kotlin/com/arkivanov/sample/shared/root/RootView.kt @@ -9,6 +9,7 @@ import com.arkivanov.decompose.value.observe import com.arkivanov.sample.shared.R import com.arkivanov.sample.shared.beginDelayedSlideTransition import com.arkivanov.sample.shared.counters.CountersView +import com.arkivanov.sample.shared.root.RootComponent.Child.CardsChild import com.arkivanov.sample.shared.root.RootComponent.Child.CountersChild import com.arkivanov.sample.shared.root.RootComponent.Child.CustomNavigationChild import com.arkivanov.sample.shared.root.RootComponent.Child.DynamicFeaturesChild @@ -27,6 +28,7 @@ fun ViewContext.RootView(component: RootComponent): View { val newView: View = when (val child = newStack.active.instance) { is CountersChild -> CountersView(child.component) + is CardsChild, is MultiPaneChild, is DynamicFeaturesChild, is CustomNavigationChild -> NotImplementedView() @@ -50,6 +52,7 @@ fun ViewContext.RootView(component: RootComponent): View { BottomNavigationView.OnNavigationItemSelectedListener { item -> when (val id = item.itemId) { R.id.tab_counters -> component.onCountersTabClicked() + R.id.tab_cards -> component.onCardsTabClicked() R.id.tab_multipane -> component.onMultiPaneTabClicked() R.id.tab_dynamic_features -> component.onDynamicFeaturesTabClicked() R.id.tab_custom_navigation -> component.onCustomNavigationTabClicked() @@ -67,6 +70,7 @@ fun ViewContext.RootView(component: RootComponent): View { navigationView.selectedItemId = when (state.active.instance) { is CountersChild -> R.id.tab_counters + is CardsChild -> R.id.tab_cards is MultiPaneChild -> R.id.tab_multipane is DynamicFeaturesChild -> R.id.tab_dynamic_features is CustomNavigationChild -> R.id.tab_custom_navigation @@ -82,7 +86,8 @@ private val RootComponent.Child.index: Int get() = when (this) { is CountersChild -> 0 - is MultiPaneChild -> 1 - is DynamicFeaturesChild -> 2 - is CustomNavigationChild -> 3 + is CardsChild -> 1 + is MultiPaneChild -> 2 + is DynamicFeaturesChild -> 3 + is CustomNavigationChild -> 4 } diff --git a/sample/shared/shared/src/androidMain/res/drawable/ic_tab_cards.xml b/sample/shared/shared/src/androidMain/res/drawable/ic_tab_cards.xml new file mode 100644 index 000000000..352cce1bc --- /dev/null +++ b/sample/shared/shared/src/androidMain/res/drawable/ic_tab_cards.xml @@ -0,0 +1,11 @@ + + + diff --git a/sample/shared/shared/src/androidMain/res/drawable/ic_tab_custom_navigation.xml b/sample/shared/shared/src/androidMain/res/drawable/ic_tab_custom_navigation.xml index 9790a2041..c0e1040df 100644 --- a/sample/shared/shared/src/androidMain/res/drawable/ic_tab_custom_navigation.xml +++ b/sample/shared/shared/src/androidMain/res/drawable/ic_tab_custom_navigation.xml @@ -1,6 +1,10 @@ - - + + diff --git a/sample/shared/shared/src/androidMain/res/menu/root_tabs.xml b/sample/shared/shared/src/androidMain/res/menu/root_tabs.xml index 6abb10a47..515bef07e 100644 --- a/sample/shared/shared/src/androidMain/res/menu/root_tabs.xml +++ b/sample/shared/shared/src/androidMain/res/menu/root_tabs.xml @@ -3,20 +3,25 @@ + android:title="@null" /> + + + android:title="@null" /> + android:title="@null" /> + android:title="@null" /> diff --git a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/cards/CardsComponent.kt b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/cards/CardsComponent.kt new file mode 100644 index 000000000..14b74b71a --- /dev/null +++ b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/cards/CardsComponent.kt @@ -0,0 +1,14 @@ +package com.arkivanov.sample.shared.cards + +import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.value.Value +import com.arkivanov.sample.shared.cards.card.CardComponent + +interface CardsComponent { + + val stack: Value> + + fun onCardSwiped(index: Int) + fun onAddClicked() + fun onRemoveClicked() +} diff --git a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/cards/DefaultCardsComponent.kt b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/cards/DefaultCardsComponent.kt new file mode 100644 index 000000000..cd42866d0 --- /dev/null +++ b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/cards/DefaultCardsComponent.kt @@ -0,0 +1,84 @@ +package com.arkivanov.sample.shared.cards + +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.items +import com.arkivanov.decompose.router.stack.navigate +import com.arkivanov.decompose.router.stack.pop +import com.arkivanov.decompose.router.stack.push +import com.arkivanov.decompose.value.Value +import com.arkivanov.essenty.parcelable.Parcelable +import com.arkivanov.essenty.parcelable.Parcelize +import com.arkivanov.sample.shared.cards.card.CardComponent +import com.arkivanov.sample.shared.cards.card.DefaultCardComponent + +class DefaultCardsComponent( + componentContext: ComponentContext, +) : CardsComponent, ComponentContext by componentContext { + + private val navigation = StackNavigation() + + private val _stack: Value> = + childStack( + source = navigation, + initialStack = { + COLORS.mapIndexed { index, color -> + Config(color = color, number = index + 1) + } + }, + childFactory = ::card, + ) + + override val stack: Value> = _stack + + private fun card(config: Config, componentContext: ComponentContext): CardComponent = + DefaultCardComponent( + componentContext = componentContext, + color = config.color, + number = config.number, + ) + + override fun onCardSwiped(index: Int) { + navigation.navigate { stack -> + val config = stack[index] + listOf(config) + (stack - config) + } + } + + override fun onAddClicked() { + if (_stack.items.size >= 10) { + return + } + + val maxNumber = _stack.items.maxOf { it.configuration.number } + + navigation.push( + Config( + color = COLORS[maxNumber % COLORS.size], + number = maxNumber + 1, + ) + ) + } + + override fun onRemoveClicked() { + navigation.pop() + } + + private companion object { + private val COLORS = + listOf( + 0xFF2980B9, + 0xFFE74C3C, + 0xFF27AE60, + 0xFFF39C12, + ) + } + + @Parcelize + private data class Config( + val color: Long, + val number: Int, + ) : Parcelable +} diff --git a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/cards/card/CardComponent.kt b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/cards/card/CardComponent.kt new file mode 100644 index 000000000..c1db1a990 --- /dev/null +++ b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/cards/card/CardComponent.kt @@ -0,0 +1,15 @@ +package com.arkivanov.sample.shared.cards.card + +import com.arkivanov.decompose.value.Value + +interface CardComponent { + + val model: Value + + data class Model( + val color: Long, + val title: String, + val status: String = "", + val text: String = "", + ) +} diff --git a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/cards/card/DefaultCardComponent.kt b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/cards/card/DefaultCardComponent.kt new file mode 100644 index 000000000..73306f058 --- /dev/null +++ b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/cards/card/DefaultCardComponent.kt @@ -0,0 +1,97 @@ +package com.arkivanov.sample.shared.cards.card + +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import com.arkivanov.decompose.value.update +import com.arkivanov.essenty.instancekeeper.InstanceKeeper +import com.arkivanov.essenty.instancekeeper.getOrCreate +import com.arkivanov.essenty.lifecycle.subscribe +import com.arkivanov.essenty.parcelable.Parcelable +import com.arkivanov.essenty.parcelable.Parcelize +import com.arkivanov.essenty.statekeeper.consume +import com.arkivanov.sample.shared.cards.card.CardComponent.Model +import com.badoo.reaktive.disposable.Disposable +import com.badoo.reaktive.disposable.scope.DisposableScope +import com.badoo.reaktive.observable.observableInterval +import com.badoo.reaktive.observable.subscribe +import com.badoo.reaktive.scheduler.Scheduler +import com.badoo.reaktive.scheduler.mainScheduler +import com.badoo.reaktive.subject.behavior.BehaviorObservable +import com.badoo.reaktive.subject.behavior.BehaviorSubject + +class DefaultCardComponent( + componentContext: ComponentContext, + color: Long, + number: Int, + tickScheduler: Scheduler = mainScheduler, +) : CardComponent, ComponentContext by componentContext, DisposableScope by DisposableScope() { + + private val handler = + instanceKeeper.getOrCreate { + Handler( + initialCount = stateKeeper.consume(key = KEY_SAVED_STATE)?.count ?: 0, + tickScheduler = tickScheduler, + ) + } + + private val _model = MutableValue(Model(color = color, title = number.toString())) + override val model: Value = _model + + init { + stateKeeper.register(KEY_SAVED_STATE) { SavedState(count = handler.count.value) } + + handler.count.subscribeScoped { count -> + _model.update { it.copy(text = "Count: $count") } + } + + lifecycle.subscribe( + onCreate = { setStatus("Created") }, + onStart = { setStatus("Started") }, + onResume = { setStatus("Resumed") }, + onPause = { setStatus("Paused") }, + onStop = { setStatus("Stopped") }, + onDestroy = { setStatus("Destroyed") }, + ) + + lifecycle.subscribe( + onStart = handler::start, + onStop = handler::stop, + ) + } + + private fun setStatus(status: String) { + _model.update { it.copy(status = "Status: $status") } + } + + private companion object { + const val KEY_SAVED_STATE: String = "SAVED_STATE" + } + + private class Handler( + initialCount: Int, + private val tickScheduler: Scheduler, + ) : InstanceKeeper.Instance { + private val _count = BehaviorSubject(initialCount) + val count: BehaviorObservable = _count + private var disposable: Disposable? = null + + fun start() { + disposable = + observableInterval(periodMillis = 250L, scheduler = tickScheduler) + .subscribe { _count.onNext(_count.value + 1) } + } + + fun stop() { + disposable?.dispose() + disposable = null + } + + override fun onDestroy() { + stop() + } + } + + @Parcelize + private class SavedState(val count: Int) : Parcelable +} diff --git a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/root/DefaultRootComponent.kt b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/root/DefaultRootComponent.kt index 1e4c4adea..a61aea18f 100644 --- a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/root/DefaultRootComponent.kt +++ b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/root/DefaultRootComponent.kt @@ -10,6 +10,7 @@ import com.arkivanov.decompose.router.stack.webhistory.WebHistoryController import com.arkivanov.decompose.value.Value import com.arkivanov.essenty.parcelable.Parcelable import com.arkivanov.essenty.parcelable.Parcelize +import com.arkivanov.sample.shared.cards.DefaultCardsComponent import com.arkivanov.sample.shared.counters.DefaultCountersComponent import com.arkivanov.sample.shared.customnavigation.DefaultCustomNavigationComponent import com.arkivanov.sample.shared.dynamicfeatures.DefaultDynamicFeaturesComponent @@ -52,6 +53,7 @@ class DefaultRootComponent( private fun child(config: Config, componentContext: ComponentContext): Child = when (config) { is Config.Counters -> CountersChild(DefaultCountersComponent(componentContext)) + is Config.Cards -> Child.CardsChild(DefaultCardsComponent(componentContext)) is Config.MultiPane -> MultiPaneChild(DefaultMultiPaneComponent(componentContext)) is Config.DynamicFeatures -> DynamicFeaturesChild(DefaultDynamicFeaturesComponent(componentContext, featureInstaller)) is Config.CustomNavigation -> CustomNavigationChild(DefaultCustomNavigationComponent(componentContext)) @@ -61,6 +63,10 @@ class DefaultRootComponent( navigation.bringToFront(Config.Counters) } + override fun onCardsTabClicked() { + navigation.bringToFront(Config.Cards) + } + override fun onMultiPaneTabClicked() { navigation.bringToFront(Config.MultiPane) } @@ -75,6 +81,7 @@ class DefaultRootComponent( private companion object { private const val WEB_PATH_COUNTERS = "counters" + private const val WEB_PATH_CARDS = "cards" private const val WEB_PATH_MULTI_PANE = "multi-pane" private const val WEB_PATH_DYNAMIC_FEATURES = "dynamic-features" private const val WEB_PATH_CUSTOM_NAVIGATION = "custom-navigation" @@ -88,6 +95,7 @@ class DefaultRootComponent( private fun getPathForConfig(config: Config): String = when (config) { Config.Counters -> "/$WEB_PATH_COUNTERS" + Config.Cards -> "/$WEB_PATH_CARDS" Config.MultiPane -> "/$WEB_PATH_MULTI_PANE" Config.DynamicFeatures -> "/$WEB_PATH_DYNAMIC_FEATURES" Config.CustomNavigation -> "/$WEB_PATH_CUSTOM_NAVIGATION" @@ -96,6 +104,7 @@ class DefaultRootComponent( private fun getConfigForPath(path: String): Config = when (path.removePrefix("/")) { WEB_PATH_COUNTERS -> Config.Counters + WEB_PATH_CARDS -> Config.Cards WEB_PATH_MULTI_PANE -> Config.MultiPane WEB_PATH_DYNAMIC_FEATURES -> Config.DynamicFeatures WEB_PATH_CUSTOM_NAVIGATION -> Config.CustomNavigation @@ -114,6 +123,16 @@ class DefaultRootComponent( private fun readResolve(): Any = Counters } + @Parcelize + object Cards : Config { + /** + * Only required for state preservation on JVM/desktop via StateKeeper, as it uses Serializable. + * Temporary workaround for https://youtrack.jetbrains.com/issue/KT-40218. + */ + @Suppress("unused") + private fun readResolve(): Any = Cards + } + @Parcelize object MultiPane : Config { /** diff --git a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/root/RootComponent.kt b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/root/RootComponent.kt index fcbeaeb91..9367224e5 100644 --- a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/root/RootComponent.kt +++ b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/root/RootComponent.kt @@ -2,6 +2,7 @@ package com.arkivanov.sample.shared.root import com.arkivanov.decompose.router.stack.ChildStack import com.arkivanov.decompose.value.Value +import com.arkivanov.sample.shared.cards.CardsComponent import com.arkivanov.sample.shared.counters.CountersComponent import com.arkivanov.sample.shared.customnavigation.CustomNavigationComponent import com.arkivanov.sample.shared.dynamicfeatures.DynamicFeaturesComponent @@ -12,12 +13,14 @@ interface RootComponent { val childStack: Value> fun onCountersTabClicked() + fun onCardsTabClicked() fun onMultiPaneTabClicked() fun onDynamicFeaturesTabClicked() fun onCustomNavigationTabClicked() sealed class Child { class CountersChild(val component: CountersComponent) : Child() + class CardsChild(val component: CardsComponent) : Child() class MultiPaneChild(val component: MultiPaneComponent) : Child() class DynamicFeaturesChild(val component: DynamicFeaturesComponent) : Child() class CustomNavigationChild(val component: CustomNavigationComponent) : Child() diff --git a/sample/shared/shared/src/jsMain/kotlin/com/arkivanov/sample/shared/root/RootContent.kt b/sample/shared/shared/src/jsMain/kotlin/com/arkivanov/sample/shared/root/RootContent.kt index caa755c62..94084f6f6 100644 --- a/sample/shared/shared/src/jsMain/kotlin/com/arkivanov/sample/shared/root/RootContent.kt +++ b/sample/shared/shared/src/jsMain/kotlin/com/arkivanov/sample/shared/root/RootContent.kt @@ -5,6 +5,7 @@ import com.arkivanov.sample.shared.componentContent import com.arkivanov.sample.shared.counters.CountersContent import com.arkivanov.sample.shared.dynamicfeatures.DynamicFeaturesContent import com.arkivanov.sample.shared.multipane.MultiPaneContent +import com.arkivanov.sample.shared.root.RootComponent.Child.CardsChild import com.arkivanov.sample.shared.root.RootComponent.Child.CountersChild import com.arkivanov.sample.shared.root.RootComponent.Child.CustomNavigationChild import com.arkivanov.sample.shared.root.RootComponent.Child.DynamicFeaturesChild @@ -53,6 +54,7 @@ var RootContent: FC> = FC { props -> when (val child = childStack.active.instance) { is CountersChild -> componentContent(component = child.component, content = CountersContent) + is CardsChild -> NotImplementedContent() is MultiPaneChild -> componentContent(component = child.component, content = MultiPaneContent) is DynamicFeaturesChild -> componentContent(component = child.component, content = DynamicFeaturesContent) is CustomNavigationChild -> NotImplementedContent() @@ -69,6 +71,7 @@ var RootContent: FC> = FC { props -> value = when (childStack.active.instance) { is CountersChild -> TabItem.COUNTERS + is CardsChild -> TabItem.CARDS is MultiPaneChild -> TabItem.MULTI_PANE is DynamicFeaturesChild -> TabItem.DYNAMIC_FEATURES is CustomNavigationChild -> TabItem.CUSTOM_NAVIGATION @@ -77,6 +80,7 @@ var RootContent: FC> = FC { props -> onChange = { _, newValue -> when (newValue.unsafeCast()) { TabItem.COUNTERS -> props.component.onCountersTabClicked() + TabItem.CARDS -> props.component.onCardsTabClicked() TabItem.MULTI_PANE -> props.component.onMultiPaneTabClicked() TabItem.DYNAMIC_FEATURES -> props.component.onDynamicFeaturesTabClicked() TabItem.CUSTOM_NAVIGATION -> props.component.onCustomNavigationTabClicked() @@ -89,6 +93,12 @@ var RootContent: FC> = FC { props -> icon = Icon.create { +"pin" } } + BottomNavigationAction { + value = TabItem.CARDS + label = ReactNode("Cards") + icon = Icon.create { +"swipe_up" } + } + BottomNavigationAction { value = TabItem.MULTI_PANE label = ReactNode("Multi-Pane") @@ -111,5 +121,9 @@ var RootContent: FC> = FC { props -> } private enum class TabItem { - COUNTERS, MULTI_PANE, DYNAMIC_FEATURES, CUSTOM_NAVIGATION + COUNTERS, + CARDS, + MULTI_PANE, + DYNAMIC_FEATURES, + CUSTOM_NAVIGATION, }