Skip to content

Commit

Permalink
Merge pull request #700 from arkivanov/update-v3.0
Browse files Browse the repository at this point in the history
Update v3.0 branch
  • Loading branch information
arkivanov authored Apr 25, 2024
2 parents 556628d + c2ce113 commit d1f0206
Show file tree
Hide file tree
Showing 31 changed files with 823 additions and 77 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Additional resources:
* Shared navigation logic
* Lifecycle-aware components
* Components in the back stack are not destroyed, they continue working in background without UI
* State preservation (on Android and experimentally on Apple targets)
* State preservation (automatically on Android, manually on all other targets via `kotlinx-serialization`)
* Instances retaining (aka ViewModels) over configuration changes (mostly useful in Android)

## Setup
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.arkivanov.decompose.lifecycle

import com.arkivanov.decompose.InternalDecomposeApi
import com.arkivanov.essenty.lifecycle.Lifecycle
import com.arkivanov.essenty.lifecycle.Lifecycle.State
import com.arkivanov.essenty.lifecycle.LifecycleRegistry
import com.arkivanov.essenty.lifecycle.create
import com.arkivanov.essenty.lifecycle.destroy
Expand All @@ -21,11 +22,23 @@ class MergedLifecycle private constructor(
constructor(lifecycle1: Lifecycle, lifecycle2: Lifecycle) : this(LifecycleRegistry(), lifecycle1, lifecycle2)

init {
moveTo(minOf(lifecycle1.state, lifecycle2.state))
var state1 = if (lifecycle1.state == State.DESTROYED) State.DESTROYED else State.INITIALIZED
var state2 = if (lifecycle2.state == State.DESTROYED) State.DESTROYED else State.INITIALIZED

if ((lifecycle1.state != Lifecycle.State.DESTROYED) && (lifecycle2.state != Lifecycle.State.DESTROYED)) {
val observer1 = CallbacksImpl { state -> moveTo(minOf(state, lifecycle2.state)) }
val observer2 = CallbacksImpl { state -> moveTo(minOf(state, lifecycle1.state)) }
moveTo(minOf(state1, state2))

if ((state1 != State.DESTROYED) && (state2 != State.DESTROYED)) {
val observer1 =
CallbacksImpl { state ->
state1 = state
moveTo(minOf(state, state2))
}

val observer2 =
CallbacksImpl { state ->
state2 = state
moveTo(minOf(state, state1))
}

lifecycle1.subscribe(observer1)
lifecycle2.subscribe(observer2)
Expand All @@ -37,91 +50,91 @@ class MergedLifecycle private constructor(
}
}

private fun moveTo(state: Lifecycle.State) {
private fun moveTo(state: State) {
when (state) {
Lifecycle.State.DESTROYED -> moveToDestroyed()
Lifecycle.State.INITIALIZED -> Unit
Lifecycle.State.CREATED -> moveToCreated()
Lifecycle.State.STARTED -> moveToStarted()
Lifecycle.State.RESUMED -> moveToResumed()
State.DESTROYED -> moveToDestroyed()
State.INITIALIZED -> Unit
State.CREATED -> moveToCreated()
State.STARTED -> moveToStarted()
State.RESUMED -> moveToResumed()
}
}

private fun moveToDestroyed() {
when (registry.state) {
Lifecycle.State.DESTROYED -> Unit
State.DESTROYED -> Unit

Lifecycle.State.INITIALIZED -> {
State.INITIALIZED -> {
registry.create()
registry.destroy()
}

Lifecycle.State.CREATED,
Lifecycle.State.STARTED,
Lifecycle.State.RESUMED -> registry.destroy()
State.CREATED,
State.STARTED,
State.RESUMED -> registry.destroy()
}
}

private fun moveToCreated() {
when (registry.state) {
Lifecycle.State.DESTROYED -> Unit
Lifecycle.State.INITIALIZED -> registry.create()
State.DESTROYED -> Unit
State.INITIALIZED -> registry.create()

Lifecycle.State.CREATED -> Unit
State.CREATED -> Unit

Lifecycle.State.STARTED,
Lifecycle.State.RESUMED -> registry.stop()
State.STARTED,
State.RESUMED -> registry.stop()
}
}

private fun moveToStarted() {
when (registry.state) {
Lifecycle.State.INITIALIZED,
Lifecycle.State.CREATED -> registry.start()
State.INITIALIZED,
State.CREATED -> registry.start()

Lifecycle.State.RESUMED -> registry.pause()
State.RESUMED -> registry.pause()

Lifecycle.State.DESTROYED,
Lifecycle.State.STARTED -> Unit
State.DESTROYED,
State.STARTED -> Unit
}
}

private fun moveToResumed() {
when (registry.state) {
Lifecycle.State.INITIALIZED,
Lifecycle.State.CREATED,
Lifecycle.State.STARTED -> registry.resume()
State.INITIALIZED,
State.CREATED,
State.STARTED -> registry.resume()

Lifecycle.State.RESUMED,
Lifecycle.State.DESTROYED -> Unit
State.RESUMED,
State.DESTROYED -> Unit
}
}

private class CallbacksImpl(
private val onStateChanged: (Lifecycle.State) -> Unit,
private val onStateChanged: (State) -> Unit,
) : Lifecycle.Callbacks {
override fun onCreate() {
onStateChanged(Lifecycle.State.CREATED)
onStateChanged(State.CREATED)
}

override fun onStart() {
onStateChanged(Lifecycle.State.STARTED)
onStateChanged(State.STARTED)
}

override fun onResume() {
onStateChanged(Lifecycle.State.RESUMED)
onStateChanged(State.RESUMED)
}

override fun onPause() {
onStateChanged(Lifecycle.State.STARTED)
onStateChanged(State.STARTED)
}

override fun onStop() {
onStateChanged(Lifecycle.State.CREATED)
onStateChanged(State.CREATED)
}

override fun onDestroy() {
onStateChanged(Lifecycle.State.DESTROYED)
onStateChanged(State.DESTROYED)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,6 @@ class MergedLifecycleTest {
lifecycle1.create()
lifecycle1.destroy()

toString()
val merged = MergedLifecycle(lifecycle1, lifecycle2)

assertEquals(Lifecycle.State.DESTROYED, merged.state)
Expand Down Expand Up @@ -456,9 +455,41 @@ class MergedLifecycleTest {
lifecycle2.assertNoSubscribers()
}

@Test
fun GIVEN_lifecycle1_resumed_and_not_emitting_on_subscribe_and_lifecycle2_initialized_WHEN_lifecycle2_and_lifecycle1_resumed_THEN_onCreate_onStart_onResume_called() {
val callbacks = TestLifecycleCallbacks()
val lifecycle1 = TestLifecycleRegistry(getState = { Lifecycle.State.RESUMED })
val merged = MergedLifecycle(lifecycle1, lifecycle2)
merged.subscribe(callbacks)

lifecycle2.resume()
lifecycle1.onCreate()
lifecycle1.onStart()
lifecycle1.onResume()

callbacks.assertEvents(Event.ON_CREATE, Event.ON_START, Event.ON_RESUME)
}

@Test
fun GIVEN_lifecycle2_resumed_and_not_emitting_on_subscribe_and_lifecycle1_initialized_WHEN_lifecycle1_and_lifecycle2_resumed_THEN_onCreate_onStart_onResume_called() {
val callbacks = TestLifecycleCallbacks()
val lifecycle2 = TestLifecycleRegistry(getState = { Lifecycle.State.RESUMED })
val merged = MergedLifecycle(lifecycle1, lifecycle2)
merged.subscribe(callbacks)

lifecycle1.resume()
lifecycle2.onCreate()
lifecycle2.onStart()
lifecycle2.onResume()

callbacks.assertEvents(Event.ON_CREATE, Event.ON_START, Event.ON_RESUME)
}

private class TestLifecycleRegistry(
private val registry: LifecycleRegistry = LifecycleRegistry()
private val registry: LifecycleRegistry = LifecycleRegistry(),
private val getState: () -> Lifecycle.State = registry::state,
) : LifecycleRegistry by registry {
override val state: Lifecycle.State get() = getState.invoke()
private val callbacks = HashSet<Lifecycle.Callbacks>()

override fun subscribe(callbacks: Lifecycle.Callbacks) {
Expand Down
67 changes: 67 additions & 0 deletions docs/component/back-button.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,70 @@ 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.md#predictive-back-gesture).

## Back button handling in Compose

By default, Decompose doesn't propagate `LocalOnBackPressedDispatcherOwner` from Jetpack `activity-compose` library. Therefore, using `BackHandler {}` Composable API from `activity-compose` will register the callback in the root `OnBackPressedDispatcher`. This will cause the Composable handler to always intercept the back button, regardless of the component hierarchy.

If you are using `BackHandler` from Jetpack `activity-compose` library, make sure that it's enabled only when needed. This can be done by supplying the `enabled` argument. See the [official docs](https://developer.android.com/jetpack/compose/libraries#handling_the_system_back_button) for more information.

Another approach is to use the `BackHandler` provided by Essenty library and implemented for you by Decompose. Expose `BackHandler` from your component and register/unregister the callback in your Composable function.

```kotlin title="The component interface"
import com.arkivanov.essenty.backhandler.BackHandlerOwner

interface SomeComponent : BackHandlerOwner {
// Omitted code
}
```

```kotlin title="Implementing the component"
class DefaultSomeComponent(
componentContext: ComponentContext,
) : ComponentContext by componentContext {
// No need to implement BackHandlerOwner interface, already implemented by ComponentContext
}
```

```kotlin title="Custom BackHandler Composable API"
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import com.arkivanov.essenty.backhandler.BackCallback
import com.arkivanov.essenty.backhandler.BackHandler

@Composable
fun BackHandler(backHandler: BackHandler, isEnabled: Boolean = true, onBack: () -> Unit) {
val currentOnBack by rememberUpdatedState(onBack)

val callback =
remember {
BackCallback(isEnabled = isEnabled) {
currentOnBack()
}
}

SideEffect { callback.isEnabled = isEnabled }

DisposableEffect(backHandler) {
backHandler.register(callback)
onDispose { backHandler.unregister(callback) }
}
}
```

Now we can use the newly created `BackHandler` Composable API similarly to the one provided by Jetpack.

```kotlin
import androidx.compose.runtime.Composable

@Composable
fun SomeContent(component: SomeComponent) {
BackHandler(backHandler = component.backHandler) {
// Handle the back button here
}
}
```
3 changes: 2 additions & 1 deletion docs/component/child-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ There are two common ways to work with child components:
A permanent child component should be always instantiated during the initialisation of the parent, and it is automatically destroyed at the end of the parent's lifecycle. It is possible to manually control the lifecycle of a permanent child component, e.g. resume it, pause or stop. But permanent child components must never be destroyed manually.

!!!warning
Every child component needs its own `ComponentContext`. Never pass parent's `ComponentContext` to children, always use either the navigation or the `childContext(...)` function.

Every child component needs its own `ComponentContext`. Never pass parent's `ComponentContext` to children, always use either navigation or the `childContext(...)` function. There may be a runtime crash if the same component context is passed to multiple components and they use same keys for `StateKeeper`, `InstanceKeeper` or navigation.

A child `ComponentContext` can be created using the following extension function:

Expand Down
68 changes: 53 additions & 15 deletions docs/component/custom-component-context.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,67 @@
# Custom `ComponentContext`

If one is needing `ComponentContext` to have extra functionality that is not already provided. It is possible to create a custom `ComponentContext` that could be decorated with the desired functionality of your choice.
If you need `ComponentContext` to have extra functionality that is not already provided, it is possible to create a custom component context that could be decorated with the desired functionality of your choice. For instance, in some cases it might be useful to create a component context interface with additional properties required by most of the components.

## Create and implement custom ComponentContext

For example, to create your own custom `ComponentContext` one must first create an interface that extends `ComponentContext` and then provide its implementation.
=== "Before version 3.0.0-alpha09"

```kotlin
import com.arkivanov.decompose.ComponentContext
To define a custom component context, create an interface that extends the `ComponentContext` interface, then implement it by delegating to the existing `ComponentContext`.

```kotlin
import com.arkivanov.decompose.ComponentContext

interface AppComponentContext : ComponentContext {

val logger: Logger // Additional property
}

class DefaultAppComponentContext(
componentContext: ComponentContext,
override val logger: Logger,
) : AppComponentContext, ComponentContext by componentContext
```

interface AppComponentContext : ComponentContext {
=== "Since version 3.0.0-alpha09"

// Custom things here
}
To define a custom component context, create an interface that extends the `GenericComponentContext` interface, then implement it by delegating parts to the existing `ComponentContext`. Also, implement the `componentContextFactory` property to allow Decompose creating new instances of the custom component context type.

class DefaultAppComponentContext(
componentContext: ComponentContext,
// Additional dependencies here
) : AppComponentContext, ComponentContext by componentContext {
```kotlin
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.ComponentContextFactory
import com.arkivanov.decompose.GenericComponentContext
import com.arkivanov.essenty.backhandler.BackHandlerOwner
import com.arkivanov.essenty.instancekeeper.InstanceKeeperOwner
import com.arkivanov.essenty.lifecycle.LifecycleOwner
import com.arkivanov.essenty.statekeeper.StateKeeperOwner

interface AppComponentContext : GenericComponentContext<AppComponentContext> {

val logger: Logger // Additional property
}

class DefaultAppComponentContext(
componentContext: ComponentContext,
override val logger: Logger,
) : AppComponentContext,
LifecycleOwner by componentContext,
StateKeeperOwner by componentContext,
InstanceKeeperOwner by componentContext,
BackHandlerOwner by componentContext {

override val componentContextFactory: ComponentContextFactory<AppComponentContext> =
ComponentContextFactory { lifecycle, stateKeeper, instanceKeeper, backHandler ->
val ctx = componentContext.componentContextFactory(lifecycle, stateKeeper, instanceKeeper, backHandler)
DefaultAppComponentContext(ctx, logger)
}
}
```

// Custom things implementation here
}
```
## Custom child ComponentContext (before v3.0.0-alpha09)

!!!info "Not required since version 3.0.0-alpha09"

## Custom child ComponentContext
This section is only relevant for Decompose versions before `3.0.0-alpha09`. Since that version, the custom component context can be created the usual way - using the `childContext` extension function.

The default [ComponentContext#childContext](child-components.md#adding-a-child-component-manually) extension function returns the default `ComponentContext`. In order to create custom child `ComponentContext`, a special extension function is required.

Expand Down
4 changes: 4 additions & 0 deletions docs/component/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ A component is just a normal class that encapsulates some logic and possibly ano

UI is optional and is pluggable from outside of components. Components do not depend on UI, the UI depends on components.

Unlike the traditional approach with `ViewModels` and navigation from UI (when a `ViewModel` is passed to a Composable function or a SwiftUI View, etc.), Decompose uses the Component concept for navigation. So the UI is only responsible for displaying the information, and everything else is behind the component boundary. This allows more code to be shared between platforms while keeping the UI layer thinner.

Additionally, this approach significantly simplifies testing. A component can be tested by a unit or integration test, often without instrumentation, which is fast and reliable. Plus, various tools can be used for UI testing, including but not limited to screenshot testing.

## Component hierarchy

<img src="https://raw.githubusercontent.com/arkivanov/Decompose/master/docs/media/ComponentHierarchy.png" width="512">
Expand Down
Loading

0 comments on commit d1f0206

Please sign in to comment.