Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update v3.0 branch #700

Merged
merged 22 commits into from
Apr 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
551324a
Added "Hosting a component in navigation-compose" section to docs
arkivanov Feb 11, 2024
802c27a
Updated navigation-compose-component.md
arkivanov Feb 11, 2024
8d56415
Updated navigation-compose-component.md
arkivanov Feb 11, 2024
9649a2a
Added Pages sample tab
arkivanov Feb 17, 2024
208b9ff
Merge pull request #650 from arkivanov/pages-sample-2
arkivanov Feb 17, 2024
5c8cac8
Updated samples docs page
arkivanov Feb 17, 2024
091a3f9
Updated predictive back gesture docs
arkivanov Feb 19, 2024
7e6d548
Merge pull request #657 from arkivanov/predictive-back-docs
arkivanov Feb 20, 2024
d882467
Expanded docs on child component context
arkivanov Mar 10, 2024
737e4af
Added the initial FAQ section
arkivanov Mar 10, 2024
8b7c87f
Described back button with Compose in the docs
arkivanov Mar 11, 2024
4125c31
Described components in the docs in more detail
arkivanov Mar 11, 2024
8ece234
Added an example of Generic Navigation to docs
arkivanov Mar 11, 2024
c6e9423
Update README.md
arkivanov Mar 17, 2024
64b7ad7
Updated docs with the new custom component context API
arkivanov Mar 14, 2024
9e95894
Update quick-start.md
arkivanov Mar 30, 2024
35f1e11
Update installation.md
arkivanov Apr 7, 2024
4fd26e6
Updated root integration quick start docs for iOS
arkivanov Apr 14, 2024
4c0b600
Fixed a bug in MergedLifecycle when one of the lifecycles emits async…
arkivanov Apr 23, 2024
f6b076b
Merge pull request #698 from arkivanov/fix-MergedLifecycle
arkivanov Apr 24, 2024
0917cc1
Bumped version to 2.2.3
arkivanov Apr 25, 2024
c2ce113
Merge branch 'master' into update-v3.0
arkivanov Apr 25, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion 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
Loading