Repository: arkivanov/Decompose
Pull request #396 (2023-05-29 11:29:31)

Added Child Pages navigation model
Closes #363


Patch:
diff --git a/decompose/api/android/decompose.api b/decompose/api/android/decompose.api
index 5da005a7..37612bb5 100644
--- a/decompose/api/android/decompose.api
+++ b/decompose/api/android/decompose.api
@@ -180,6 +180,87 @@ public final class com/arkivanov/decompose/router/overlay/ValueExtKt {
 	public static final fun getOverlay (Lcom/arkivanov/decompose/value/Value;)Lcom/arkivanov/decompose/Child$Created;
 }
 
+public final class com/arkivanov/decompose/router/pages/ChildPages {
+	public fun <init> ()V
+	public fun <init> (Ljava/util/List;I)V
+	public final fun component1 ()Ljava/util/List;
+	public final fun component2 ()I
+	public final fun copy (Ljava/util/List;I)Lcom/arkivanov/decompose/router/pages/ChildPages;
+	public static synthetic fun copy$default (Lcom/arkivanov/decompose/router/pages/ChildPages;Ljava/util/List;IILjava/lang/Object;)Lcom/arkivanov/decompose/router/pages/ChildPages;
+	public fun equals (Ljava/lang/Object;)Z
+	public final fun getItems ()Ljava/util/List;
+	public final fun getSelectedIndex ()I
+	public fun hashCode ()I
+	public fun toString ()Ljava/lang/String;
+}
+
+public final class com/arkivanov/decompose/router/pages/ChildPagesFactoryKt {
+	public static final fun childPages (Lcom/arkivanov/decompose/ComponentContext;Lcom/arkivanov/decompose/router/pages/PagesNavigationSource;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lkotlin/jvm/functions/Function2;ZLkotlin/jvm/functions/Function2;)Lcom/arkivanov/decompose/value/Value;
+	public static final fun childPages (Lcom/arkivanov/decompose/ComponentContext;Lcom/arkivanov/decompose/router/pages/PagesNavigationSource;Lkotlin/jvm/functions/Function0;Lkotlin/reflect/KClass;Ljava/lang/String;Lkotlin/jvm/functions/Function2;ZZLkotlin/jvm/functions/Function2;)Lcom/arkivanov/decompose/value/Value;
+	public static synthetic fun childPages$default (Lcom/arkivanov/decompose/ComponentContext;Lcom/arkivanov/decompose/router/pages/PagesNavigationSource;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lkotlin/jvm/functions/Function2;ZLkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lcom/arkivanov/decompose/value/Value;
+	public static synthetic fun childPages$default (Lcom/arkivanov/decompose/ComponentContext;Lcom/arkivanov/decompose/router/pages/PagesNavigationSource;Lkotlin/jvm/functions/Function0;Lkotlin/reflect/KClass;Ljava/lang/String;Lkotlin/jvm/functions/Function2;ZZLkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lcom/arkivanov/decompose/value/Value;
+	public static final fun getDefaultPageStatus (ILcom/arkivanov/decompose/router/pages/Pages;)Lcom/arkivanov/decompose/router/children/ChildNavState$Status;
+}
+
+public final class com/arkivanov/decompose/router/pages/Pages {
+	public fun <init> ()V
+	public fun <init> (Ljava/util/List;I)V
+	public final fun component1 ()Ljava/util/List;
+	public final fun component2 ()I
+	public final fun copy (Ljava/util/List;I)Lcom/arkivanov/decompose/router/pages/Pages;
+	public static synthetic fun copy$default (Lcom/arkivanov/decompose/router/pages/Pages;Ljava/util/List;IILjava/lang/Object;)Lcom/arkivanov/decompose/router/pages/Pages;
+	public fun equals (Ljava/lang/Object;)Z
+	public final fun getItems ()Ljava/util/List;
+	public final fun getSelectedIndex ()I
+	public fun hashCode ()I
+	public fun toString ()Ljava/lang/String;
+}
+
+public abstract interface class com/arkivanov/decompose/router/pages/PagesNavigation : com/arkivanov/decompose/router/pages/PagesNavigationSource, com/arkivanov/decompose/router/pages/PagesNavigator {
+}
+
+public final class com/arkivanov/decompose/router/pages/PagesNavigationKt {
+	public static final fun PagesNavigation ()Lcom/arkivanov/decompose/router/pages/PagesNavigation;
+}
+
+public abstract interface class com/arkivanov/decompose/router/pages/PagesNavigationSource : com/arkivanov/decompose/router/children/NavigationSource {
+}
+
+public final class com/arkivanov/decompose/router/pages/PagesNavigationSource$Event {
+	public fun <init> (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V
+	public synthetic fun <init> (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
+	public final fun getOnComplete ()Lkotlin/jvm/functions/Function2;
+	public final fun getTransformer ()Lkotlin/jvm/functions/Function1;
+}
+
+public abstract interface class com/arkivanov/decompose/router/pages/PagesNavigator {
+	public abstract fun navigate (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V
+}
+
+public final class com/arkivanov/decompose/router/pages/PagesNavigatorExtKt {
+	public static final fun clear (Lcom/arkivanov/decompose/router/pages/PagesNavigator;Lkotlin/jvm/functions/Function2;)V
+	public static synthetic fun clear$default (Lcom/arkivanov/decompose/router/pages/PagesNavigator;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
+	public static final fun navigate (Lcom/arkivanov/decompose/router/pages/PagesNavigator;Lkotlin/jvm/functions/Function1;)V
+	public static final fun select (Lcom/arkivanov/decompose/router/pages/PagesNavigator;ILkotlin/jvm/functions/Function2;)V
+	public static synthetic fun select$default (Lcom/arkivanov/decompose/router/pages/PagesNavigator;ILkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
+	public static final fun selectFirst (Lcom/arkivanov/decompose/router/pages/PagesNavigator;Lkotlin/jvm/functions/Function2;)V
+	public static synthetic fun selectFirst$default (Lcom/arkivanov/decompose/router/pages/PagesNavigator;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
+	public static final fun selectLast (Lcom/arkivanov/decompose/router/pages/PagesNavigator;Lkotlin/jvm/functions/Function2;)V
+	public static synthetic fun selectLast$default (Lcom/arkivanov/decompose/router/pages/PagesNavigator;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
+	public static final fun selectNext (Lcom/arkivanov/decompose/router/pages/PagesNavigator;ZLkotlin/jvm/functions/Function2;)V
+	public static synthetic fun selectNext$default (Lcom/arkivanov/decompose/router/pages/PagesNavigator;ZLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
+	public static final fun selectPrev (Lcom/arkivanov/decompose/router/pages/PagesNavigator;ZLkotlin/jvm/functions/Function2;)V
+	public static synthetic fun selectPrev$default (Lcom/arkivanov/decompose/router/pages/PagesNavigator;ZLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
+}
+
+public final class com/arkivanov/decompose/router/pages/PagesSavedNavState$Creator : android/os/Parcelable$Creator {
+	public fun <init> ()V
+	public final fun createFromParcel (Landroid/os/Parcel;)Lcom/arkivanov/decompose/router/pages/PagesSavedNavState;
+	public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object;
+	public final fun newArray (I)[Lcom/arkivanov/decompose/router/pages/PagesSavedNavState;
+	public synthetic fun newArray (I)[Ljava/lang/Object;
+}
+
 public final class com/arkivanov/decompose/router/slot/ChildSlot {
 	public fun <init> ()V
 	public fun <init> (Lcom/arkivanov/decompose/Child$Created;)V
diff --git a/decompose/api/jvm/decompose.api b/decompose/api/jvm/decompose.api
index 14c0e6d3..7e923224 100644
--- a/decompose/api/jvm/decompose.api
+++ b/decompose/api/jvm/decompose.api
@@ -158,6 +158,79 @@ public final class com/arkivanov/decompose/router/overlay/ValueExtKt {
 	public static final fun getOverlay (Lcom/arkivanov/decompose/value/Value;)Lcom/arkivanov/decompose/Child$Created;
 }
 
+public final class com/arkivanov/decompose/router/pages/ChildPages {
+	public fun <init> ()V
+	public fun <init> (Ljava/util/List;I)V
+	public final fun component1 ()Ljava/util/List;
+	public final fun component2 ()I
+	public final fun copy (Ljava/util/List;I)Lcom/arkivanov/decompose/router/pages/ChildPages;
+	public static synthetic fun copy$default (Lcom/arkivanov/decompose/router/pages/ChildPages;Ljava/util/List;IILjava/lang/Object;)Lcom/arkivanov/decompose/router/pages/ChildPages;
+	public fun equals (Ljava/lang/Object;)Z
+	public final fun getItems ()Ljava/util/List;
+	public final fun getSelectedIndex ()I
+	public fun hashCode ()I
+	public fun toString ()Ljava/lang/String;
+}
+
+public final class com/arkivanov/decompose/router/pages/ChildPagesFactoryKt {
+	public static final fun childPages (Lcom/arkivanov/decompose/ComponentContext;Lcom/arkivanov/decompose/router/pages/PagesNavigationSource;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lkotlin/jvm/functions/Function2;ZLkotlin/jvm/functions/Function2;)Lcom/arkivanov/decompose/value/Value;
+	public static final fun childPages (Lcom/arkivanov/decompose/ComponentContext;Lcom/arkivanov/decompose/router/pages/PagesNavigationSource;Lkotlin/jvm/functions/Function0;Lkotlin/reflect/KClass;Ljava/lang/String;Lkotlin/jvm/functions/Function2;ZZLkotlin/jvm/functions/Function2;)Lcom/arkivanov/decompose/value/Value;
+	public static synthetic fun childPages$default (Lcom/arkivanov/decompose/ComponentContext;Lcom/arkivanov/decompose/router/pages/PagesNavigationSource;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lkotlin/jvm/functions/Function2;ZLkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lcom/arkivanov/decompose/value/Value;
+	public static synthetic fun childPages$default (Lcom/arkivanov/decompose/ComponentContext;Lcom/arkivanov/decompose/router/pages/PagesNavigationSource;Lkotlin/jvm/functions/Function0;Lkotlin/reflect/KClass;Ljava/lang/String;Lkotlin/jvm/functions/Function2;ZZLkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lcom/arkivanov/decompose/value/Value;
+	public static final fun getDefaultPageStatus (ILcom/arkivanov/decompose/router/pages/Pages;)Lcom/arkivanov/decompose/router/children/ChildNavState$Status;
+}
+
+public final class com/arkivanov/decompose/router/pages/Pages {
+	public fun <init> ()V
+	public fun <init> (Ljava/util/List;I)V
+	public final fun component1 ()Ljava/util/List;
+	public final fun component2 ()I
+	public final fun copy (Ljava/util/List;I)Lcom/arkivanov/decompose/router/pages/Pages;
+	public static synthetic fun copy$default (Lcom/arkivanov/decompose/router/pages/Pages;Ljava/util/List;IILjava/lang/Object;)Lcom/arkivanov/decompose/router/pages/Pages;
+	public fun equals (Ljava/lang/Object;)Z
+	public final fun getItems ()Ljava/util/List;
+	public final fun getSelectedIndex ()I
+	public fun hashCode ()I
+	public fun toString ()Ljava/lang/String;
+}
+
+public abstract interface class com/arkivanov/decompose/router/pages/PagesNavigation : com/arkivanov/decompose/router/pages/PagesNavigationSource, com/arkivanov/decompose/router/pages/PagesNavigator {
+}
+
+public final class com/arkivanov/decompose/router/pages/PagesNavigationKt {
+	public static final fun PagesNavigation ()Lcom/arkivanov/decompose/router/pages/PagesNavigation;
+}
+
+public abstract interface class com/arkivanov/decompose/router/pages/PagesNavigationSource : com/arkivanov/decompose/router/children/NavigationSource {
+}
+
+public final class com/arkivanov/decompose/router/pages/PagesNavigationSource$Event {
+	public fun <init> (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V
+	public synthetic fun <init> (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
+	public final fun getOnComplete ()Lkotlin/jvm/functions/Function2;
+	public final fun getTransformer ()Lkotlin/jvm/functions/Function1;
+}
+
+public abstract interface class com/arkivanov/decompose/router/pages/PagesNavigator {
+	public abstract fun navigate (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V
+}
+
+public final class com/arkivanov/decompose/router/pages/PagesNavigatorExtKt {
+	public static final fun clear (Lcom/arkivanov/decompose/router/pages/PagesNavigator;Lkotlin/jvm/functions/Function2;)V
+	public static synthetic fun clear$default (Lcom/arkivanov/decompose/router/pages/PagesNavigator;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
+	public static final fun navigate (Lcom/arkivanov/decompose/router/pages/PagesNavigator;Lkotlin/jvm/functions/Function1;)V
+	public static final fun select (Lcom/arkivanov/decompose/router/pages/PagesNavigator;ILkotlin/jvm/functions/Function2;)V
+	public static synthetic fun select$default (Lcom/arkivanov/decompose/router/pages/PagesNavigator;ILkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
+	public static final fun selectFirst (Lcom/arkivanov/decompose/router/pages/PagesNavigator;Lkotlin/jvm/functions/Function2;)V
+	public static synthetic fun selectFirst$default (Lcom/arkivanov/decompose/router/pages/PagesNavigator;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
+	public static final fun selectLast (Lcom/arkivanov/decompose/router/pages/PagesNavigator;Lkotlin/jvm/functions/Function2;)V
+	public static synthetic fun selectLast$default (Lcom/arkivanov/decompose/router/pages/PagesNavigator;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
+	public static final fun selectNext (Lcom/arkivanov/decompose/router/pages/PagesNavigator;ZLkotlin/jvm/functions/Function2;)V
+	public static synthetic fun selectNext$default (Lcom/arkivanov/decompose/router/pages/PagesNavigator;ZLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
+	public static final fun selectPrev (Lcom/arkivanov/decompose/router/pages/PagesNavigator;ZLkotlin/jvm/functions/Function2;)V
+	public static synthetic fun selectPrev$default (Lcom/arkivanov/decompose/router/pages/PagesNavigator;ZLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
+}
+
 public final class com/arkivanov/decompose/router/slot/ChildSlot {
 	public fun <init> ()V
 	public fun <init> (Lcom/arkivanov/decompose/Child$Created;)V
diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/ChildPages.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/ChildPages.kt
new file mode 100644
index 00000000..a0148371
--- /dev/null
+++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/ChildPages.kt
@@ -0,0 +1,29 @@
+package com.arkivanov.decompose.router.pages
+
+import com.arkivanov.decompose.Child
+import com.arkivanov.decompose.ExperimentalDecomposeApi
+
+/**
+ * A state holder for Child Pages.
+ *
+ * @param items a list of child components.
+ * @param selectedIndex an index of the selected child component.
+ * Must be within the range of [items] indices if [items] is not empty, otherwise can be any number.
+ */
+@ExperimentalDecomposeApi
+data class ChildPages<out C : Any, out T : Any>(
+    val items: List<Child<C, T>>,
+    val selectedIndex: Int,
+) {
+
+    /**
+     * Creates empty [ChildPages].
+     */
+    constructor() : this(items = emptyList(), selectedIndex = -1)
+
+    init {
+        if (items.isNotEmpty()) {
+            require(selectedIndex in items.indices)
+        }
+    }
+}
diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/ChildPagesFactory.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/ChildPagesFactory.kt
new file mode 100644
index 00000000..9e9a6136
--- /dev/null
+++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/ChildPagesFactory.kt
@@ -0,0 +1,219 @@
+package com.arkivanov.decompose.router.pages
+
+import com.arkivanov.decompose.ComponentContext
+import com.arkivanov.decompose.ExperimentalDecomposeApi
+import com.arkivanov.decompose.router.children.ChildNavState
+import com.arkivanov.decompose.router.children.ChildNavState.Status
+import com.arkivanov.decompose.router.children.NavState
+import com.arkivanov.decompose.router.children.SimpleChildNavState
+import com.arkivanov.decompose.router.children.children
+import com.arkivanov.decompose.value.Value
+import com.arkivanov.essenty.parcelable.Parcelable
+import com.arkivanov.essenty.parcelable.ParcelableContainer
+import com.arkivanov.essenty.parcelable.Parcelize
+import com.arkivanov.essenty.parcelable.consumeRequired
+import kotlin.reflect.KClass
+
+/**
+ * Initializes and manages a list of components with one selected (active) component.
+ * The list can be empty.
+ *
+ * @param source a source of navigation events.
+ * @param initialPages an initial state of Child Pages that should be set
+ * if there is no saved state. See [Pages] for more information.
+ * @param key a key of the list, must be unique if there are multiple Child Pages used in
+ * the same component.
+ * @param pageStatus a function that returns a [Status] of a page at a given index.
+ * By default, the currently selected page is [Status.ACTIVE], its two neighbours
+ * are [Status.INACTIVE], and the rest are [Status.DESTROYED]. You can implement your own
+ * logic, for example with circular behaviour.
+ * @param persistent determines whether the navigation state should pre preserved or not,
+ * default is `true`.
+ * @param handleBackButton determines whether the previous component should be automatically
+ * selected on back button press or not, default is `false`.
+ * @param childFactory a factory function that creates new child instances.
+ * @return an observable [Value] of [ChildPages].
+ */
+@ExperimentalDecomposeApi
+inline fun <reified C : Parcelable, T : Any> ComponentContext.childPages(
+    source: PagesNavigationSource<C>,
+    noinline initialPages: () -> Pages<C>,
+    key: String = "DefaultChildPages",
+    noinline pageStatus: (index: Int, Pages<C>) -> Status = ::getDefaultPageStatus,
+    persistent: Boolean = true,
+    handleBackButton: Boolean = false,
+    noinline childFactory: (configuration: C, ComponentContext) -> T,
+): Value<ChildPages<C, T>> =
+    childPages(
+        source = source,
+        initialPages = initialPages,
+        configurationClass = C::class,
+        key = key,
+        pageStatus = pageStatus,
+        persistent = persistent,
+        handleBackButton = handleBackButton,
+        childFactory = childFactory,
+    )
+
+/**
+ * Initializes and manages a list of components with one selected (active) component.
+ * The list can be empty.
+ *
+ * @param source a source of navigation events.
+ * @param initialPages an initial state of Child Pages that should be set
+ * if there is no saved state. See [Pages] for more information.
+ * @param configurationClass a [KClass] of the component configurations.
+ * @param key a key of the list, must be unique if there are multiple Child Pages used in
+ * the same component.
+ * @param pageStatus a function that returns a [Status] of a page at a given index.
+ * By default, the currently selected page is [Status.ACTIVE], its two neighbours
+ * are [Status.INACTIVE], and the rest are [Status.DESTROYED]. You can implement your own
+ * logic, for example with circular behaviour.
+ * @param persistent determines whether the navigation state should pre preserved or not,
+ * default is `true`.
+ * @param handleBackButton determines whether the previous component should be automatically
+ * selected on back button press or not, default is `false`.
+ * @param childFactory a factory function that creates new child instances.
+ * @return an observable [Value] of [ChildPages].
+ */
+@ExperimentalDecomposeApi
+fun <C : Parcelable, T : Any> ComponentContext.childPages(
+    source: PagesNavigationSource<C>,
+    initialPages: () -> Pages<C>,
+    configurationClass: KClass<out C>,
+    key: String = "DefaultChildPages",
+    pageStatus: (index: Int, Pages<C>) -> Status = ::getDefaultPageStatus,
+    persistent: Boolean = true,
+    handleBackButton: Boolean = false,
+    childFactory: (configuration: C, ComponentContext) -> T,
+): Value<ChildPages<C, T>> =
+    childPages(
+        source = source,
+        initialPages = initialPages,
+        savePages = { pages ->
+            if (persistent) {
+                ParcelableContainer(
+                    PagesSavedNavState(
+                        configurations = pages.items.map { ParcelableContainer(it) },
+                        selectedIndex = pages.selectedIndex,
+                    )
+                )
+            } else {
+                null
+            }
+        },
+        restorePages = { container ->
+            val state = container.consumeRequired<PagesSavedNavState>()
+            Pages(
+                items = state.configurations.map { it.consumeRequired(configurationClass) },
+                selectedIndex = state.selectedIndex,
+            )
+        },
+        key = key,
+        pageStatus = pageStatus,
+        handleBackButton = handleBackButton,
+        childFactory = childFactory,
+    )
+
+/**
+ * Initializes and manages a list of components with one selected (active) component.
+ * The list can be empty.
+ *
+ * @param source a source of navigation events.
+ * @param initialPages an initial state of Child Pages that should be set
+ * if there is no saved state. See [Pages] for more information.
+ * @param savePages a function that saves the provided [Pages] state into [ParcelableContainer].
+ * The navigation state is not saved if `null` is returned.
+ * @param restorePages a function that restores the [Pages] state from the provided [ParcelableContainer].
+ * If `null` is returned then [initialPages] is used instead.
+ * The restored [Pages] state must have the same amount of configurations and in the same order.
+ * @param key a key of the list, must be unique if there are multiple Child Pages used in
+ * the same component.
+ * @param pageStatus a function that returns a [Status] of a page at a given index.
+ * By default, the currently selected page is [Status.ACTIVE], its two neighbours
+ * are [Status.INACTIVE], and the rest are [Status.DESTROYED]. You can implement your own
+ * logic, for example with circular behaviour.
+ * @param handleBackButton determines whether the previous component should be automatically
+ * selected on back button press or not, default is `false`.
+ * @param childFactory a factory function that creates new child instances.
+ * @return an observable [Value] of [ChildPages].
+ */
+@ExperimentalDecomposeApi
+fun <C : Any, T : Any> ComponentContext.childPages(
+    source: PagesNavigationSource<C>,
+    initialPages: () -> Pages<C>,
+    savePages: (Pages<C>) -> ParcelableContainer?,
+    restorePages: (ParcelableContainer) -> Pages<C>?,
+    key: String = "DefaultChildPages",
+    pageStatus: (index: Int, Pages<C>) -> Status = ::getDefaultPageStatus,
+    handleBackButton: Boolean = false,
+    childFactory: (configuration: C, ComponentContext) -> T,
+): Value<ChildPages<C, T>> =
+    children(
+        source = source,
+        key = key,
+        initialState = {
+            PagesNavState(
+                pages = initialPages(),
+                pageStatus = pageStatus,
+            )
+        },
+        saveState = { savePages(it.pages) },
+        restoreState = { container ->
+            PagesNavState(
+                pages = restorePages(container) ?: initialPages(),
+                pageStatus = pageStatus,
+            )
+        },
+        navTransformer = { state, event ->
+            PagesNavState(
+                pages = event.transformer(state.pages),
+                pageStatus = pageStatus,
+            )
+        },
+        stateMapper = { state, children ->
+            ChildPages(
+                items = children,
+                selectedIndex = state.pages.selectedIndex,
+            )
+        },
+        onEventComplete = { event, newState, oldState ->
+            event.onComplete(newState.pages, oldState.pages)
+        },
+        backTransformer = { state ->
+            val selectedIndex = state.pages.selectedIndex
+            if (handleBackButton && state.pages.items.isNotEmpty() && (selectedIndex > 0)) {
+                { state.copy(pages = state.pages.copy(selectedIndex = selectedIndex - 1)) }
+            } else {
+                null
+            }
+        },
+        childFactory = childFactory,
+    )
+
+@PublishedApi
+internal fun getDefaultPageStatus(index: Int, pages: Pages<*>): Status =
+    when (index) {
+        pages.selectedIndex -> Status.ACTIVE
+        in (pages.selectedIndex - 1)..(pages.selectedIndex + 1) -> Status.INACTIVE
+        else -> Status.DESTROYED
+    }
+
+private data class PagesNavState<out C : Any>(
+    val pages: Pages<C>,
+    private val pageStatus: (index: Int, Pages<C>) -> Status,
+) : NavState<C> {
+    override val children: List<ChildNavState<C>> =
+        List(pages.items.size) { index ->
+            SimpleChildNavState(
+                configuration = pages.items[index],
+                status = pageStatus(index, pages),
+            )
+        }
+}
+
+@Parcelize
+private class PagesSavedNavState(
+    val configurations: List<ParcelableContainer>,
+    val selectedIndex: Int,
+) : Parcelable
diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/DefaultPagesNavigation.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/DefaultPagesNavigation.kt
new file mode 100644
index 00000000..cbe2d020
--- /dev/null
+++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/DefaultPagesNavigation.kt
@@ -0,0 +1,21 @@
+package com.arkivanov.decompose.router.pages
+
+import com.arkivanov.decompose.Relay
+import com.arkivanov.decompose.router.pages.PagesNavigationSource.Event
+
+internal class DefaultPagesNavigation<C : Any> : PagesNavigation<C> {
+
+    private val relay = Relay<Event<C>>()
+
+    override fun subscribe(observer: (Event<C>) -> Unit) {
+        relay.subscribe(observer)
+    }
+
+    override fun unsubscribe(observer: (Event<C>) -> Unit) {
+        relay.unsubscribe(observer)
+    }
+
+    override fun navigate(transformer: (Pages<C>) -> Pages<C>, onComplete: (newPages: Pages<C>, oldPages: Pages<C>) -> Unit) {
+        relay.accept(Event(transformer, onComplete))
+    }
+}
diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/Pages.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/Pages.kt
new file mode 100644
index 00000000..102e7c78
--- /dev/null
+++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/Pages.kt
@@ -0,0 +1,30 @@
+package com.arkivanov.decompose.router.pages
+
+import com.arkivanov.decompose.ExperimentalDecomposeApi
+
+/**
+ * Represents a state of Child Pages navigation model.
+ *
+ * @param items a list of child configurations, must be unique, can be empty.
+ * @param selectedIndex an index of the selected child configuration.
+ * Must be within the range of [items] indices if [items] is not empty, otherwise can be any number.
+ */
+@ExperimentalDecomposeApi
+data class Pages<out C : Any>(
+    val items: List<C>,
+    val selectedIndex: Int,
+) {
+
+    /**
+     * Creates empty [Pages].
+     */
+    constructor() : this(items = emptyList(), selectedIndex = -1)
+
+    init {
+        if (items.isNotEmpty()) {
+            require(selectedIndex in items.indices) {
+                "The selectedIndex argument must be with the range: ${items.indices}. Actual: $selectedIndex."
+            }
+        }
+    }
+}
diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/PagesNavigation.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/PagesNavigation.kt
new file mode 100644
index 00000000..13fbc6ad
--- /dev/null
+++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/PagesNavigation.kt
@@ -0,0 +1,17 @@
+package com.arkivanov.decompose.router.pages
+
+import com.arkivanov.decompose.ExperimentalDecomposeApi
+
+/**
+ * Represents [PagesNavigator] and [PagesNavigationSource] at the same time.
+ */
+@ExperimentalDecomposeApi
+interface PagesNavigation<C : Any> : PagesNavigator<C>, PagesNavigationSource<C>
+
+/**
+ * Returns a default implementation of [PagesNavigation].
+ * Broadcasts navigation events to all subscribed observers.
+ */
+@ExperimentalDecomposeApi
+fun <C : Any> PagesNavigation(): PagesNavigation<C> =
+    DefaultPagesNavigation()
diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/PagesNavigationSource.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/PagesNavigationSource.kt
new file mode 100644
index 00000000..10b6513c
--- /dev/null
+++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/PagesNavigationSource.kt
@@ -0,0 +1,18 @@
+package com.arkivanov.decompose.router.pages
+
+import com.arkivanov.decompose.ExperimentalDecomposeApi
+import com.arkivanov.decompose.router.children.NavigationSource
+
+/**
+ * Represents a source of navigation events for Child Pages.
+ *
+ * @see PagesNavigator
+ */
+@ExperimentalDecomposeApi
+interface PagesNavigationSource<C : Any> : NavigationSource<PagesNavigationSource.Event<C>> {
+
+    class Event<C : Any>(
+        val transformer: (Pages<C>) -> Pages<C>,
+        val onComplete: (newPages: Pages<C>, oldPages: Pages<C>) -> Unit = { _, _ -> },
+    )
+}
diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/PagesNavigator.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/PagesNavigator.kt
new file mode 100644
index 00000000..14912f5b
--- /dev/null
+++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/PagesNavigator.kt
@@ -0,0 +1,31 @@
+package com.arkivanov.decompose.router.pages
+
+import com.arkivanov.decompose.ExperimentalDecomposeApi
+
+@ExperimentalDecomposeApi
+interface PagesNavigator<C : Any> {
+
+    /**
+     * Transforms the current [Pages] state into a new one.
+     *
+     * During the navigation process, the Child Pages navigation model compares the [Pages] state with
+     * the previous one. The navigation model ensures that all removed components are destroyed, and
+     * updates lifecycles of the existing components to match the new state.
+     *
+     * The navigation is usually performed synchronously, which means that by the time
+     * the `navigate` method returns, the navigation is finished and all component lifecycles are
+     * moved into required states. However, the navigation is performed asynchronously in case of
+     * recursive invocations - e.g. `selectNext` is called from `onResume` lifecycle callback of a
+     * component being shown. All recursive invocations are queued and performed one by one once
+     * the current navigation is finished.
+     *
+     * Should be called on the main thread.
+     *
+     * @param transformer transforms the current [Pages] state to a new one.
+     * @param onComplete called when the navigation is finished (either synchronously or asynchronously).
+     */
+    fun navigate(
+        transformer: (Pages<C>) -> Pages<C>,
+        onComplete: (newPages: Pages<C>, oldPages: Pages<C>) -> Unit,
+    )
+}
diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/PagesNavigatorExt.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/PagesNavigatorExt.kt
new file mode 100644
index 00000000..d96ed5d2
--- /dev/null
+++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/PagesNavigatorExt.kt
@@ -0,0 +1,130 @@
+package com.arkivanov.decompose.router.pages
+
+import com.arkivanov.decompose.ExperimentalDecomposeApi
+
+/**
+ * A convenience method for [PagesNavigator.navigate].
+ */
+@ExperimentalDecomposeApi
+fun <C : Any> PagesNavigator<C>.navigate(transformer: (Pages<C>) -> Pages<C>) {
+    navigate(transformer = transformer, onComplete = { _, _ -> })
+}
+
+/**
+ * Selects the next component. If the currently selected component is the last one, then depending
+ * on the [circular] parameter, either nothing happens or the first component is selected.
+ *
+ * @param circular if `true`, the first component will be selected if the current one is the last one.
+ * Default value is `false`.
+ * @param onComplete called when the navigation is finished (either synchronously or asynchronously).
+ */
+@ExperimentalDecomposeApi
+fun <C : Any> PagesNavigator<C>.selectNext(
+    circular: Boolean = false,
+    onComplete: (newPages: Pages<C>, oldPages: Pages<C>) -> Unit = { _, _ -> },
+) {
+    navigate(
+        transformer = { pages ->
+            pages.coerceSelectedIndex(circular = circular) { it + 1 }
+        },
+        onComplete = onComplete,
+    )
+}
+
+/**
+ * Selects the previous component. If the currently selected component is the first one, then depending
+ * on the [circular] parameter, either nothing happens or the last component is selected.
+ *
+ * @param circular if `true`, the last component will be selected if the current one is the first one.
+ * Default value is `false`.
+ * @param onComplete called when the navigation is finished (either synchronously or asynchronously).
+ */
+@ExperimentalDecomposeApi
+fun <C : Any> PagesNavigator<C>.selectPrev(
+    circular: Boolean = false,
+    onComplete: (newPages: Pages<C>, oldPages: Pages<C>) -> Unit = { _, _ -> },
+) {
+    navigate(
+        transformer = { pages ->
+            pages.coerceSelectedIndex(circular = circular) { it - 1 }
+        },
+        onComplete = onComplete,
+    )
+}
+
+/**
+ * Selects the first component.
+ *
+ * @param onComplete called when the navigation is finished (either synchronously or asynchronously).
+ */
+@ExperimentalDecomposeApi
+fun <C : Any> PagesNavigator<C>.selectFirst(onComplete: (newPages: Pages<C>, oldPages: Pages<C>) -> Unit = { _, _ -> }) {
+    navigate(
+        transformer = { pages ->
+            pages.coerceSelectedIndex { 0 }
+        },
+        onComplete = onComplete,
+    )
+}
+
+/**
+ * Selects the last component.
+ *
+ * @param onComplete called when the navigation is finished (either synchronously or asynchronously).
+ */
+@ExperimentalDecomposeApi
+fun <C : Any> PagesNavigator<C>.selectLast(onComplete: (newPages: Pages<C>, oldPages: Pages<C>) -> Unit = { _, _ -> }) {
+    navigate(
+        transformer = { pages ->
+            pages.coerceSelectedIndex { pages.items.lastIndex }
+        },
+        onComplete = onComplete,
+    )
+}
+
+/**
+ * Selects the component at the specified [index].
+ * Throws [IllegalArgumentException] if the index is out of bounds.
+ *
+ * @param onComplete called when the navigation is finished (either synchronously or asynchronously).
+ */
+@ExperimentalDecomposeApi
+fun <C : Any> PagesNavigator<C>.select(
+    index: Int,
+    onComplete: (newPages: Pages<C>, oldPages: Pages<C>) -> Unit = { _, _ -> },
+) {
+    navigate(
+        transformer = { it.copy(selectedIndex = index) },
+        onComplete = onComplete,
+    )
+}
+
+/**
+ * Clears the current [Pages] state, i.e. removes all components.
+ *
+ * @param onComplete called when the navigation is finished (either synchronously or asynchronously).
+ */
+@ExperimentalDecomposeApi
+fun <C : Any> PagesNavigator<C>.clear(onComplete: (newPages: Pages<C>, oldPages: Pages<C>) -> Unit = { _, _ -> }) {
+    navigate(
+        transformer = { Pages() },
+        onComplete = onComplete,
+    )
+}
+
+private inline fun <C : Any> Pages<C>.coerceSelectedIndex(circular: Boolean = false, update: (Int) -> Int): Pages<C> =
+    if (items.isNotEmpty()) {
+        var newIndex = update(selectedIndex)
+
+        if (circular) {
+            if (newIndex < 0) {
+                newIndex += items.size
+            } else if (newIndex > items.lastIndex) {
+                newIndex -= items.size
+            }
+        }
+
+        copy(selectedIndex = newIndex.coerceIn(items.indices))
+    } else {
+        this
+    }
diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/slot/SlotNavigation.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/slot/SlotNavigation.kt
index 80336258..f250bf64 100644
--- a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/slot/SlotNavigation.kt
+++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/slot/SlotNavigation.kt
@@ -9,6 +9,5 @@ interface SlotNavigation<C : Any> : SlotNavigator<C>, SlotNavigationSource<C>
  * Returns a default implementation of [SlotNavigation].
  * Broadcasts navigation events to all subscribed observers.
  */
-@Suppress("FunctionName") // Factory function
 fun <C : Any> SlotNavigation(): SlotNavigation<C> =
     DefaultSlotNavigation()
diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackNavigation.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackNavigation.kt
index 23fa76dc..a969e977 100644
--- a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackNavigation.kt
+++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackNavigation.kt
@@ -9,6 +9,5 @@ interface StackNavigation<C : Any> : StackNavigator<C>, StackNavigationSource<C>
  * Returns a default implementation of [StackNavigation].
  * Broadcasts navigation events to all subscribed observers.
  */
-@Suppress("FunctionName") // Factory function
 fun <C : Any> StackNavigation(): StackNavigation<C> =
     DefaultStackNavigation()
diff --git a/extensions-compose-jetbrains/api/android/extensions-compose-jetbrains.api b/extensions-compose-jetbrains/api/android/extensions-compose-jetbrains.api
index 5204dbfb..20273336 100644
--- a/extensions-compose-jetbrains/api/android/extensions-compose-jetbrains.api
+++ b/extensions-compose-jetbrains/api/android/extensions-compose-jetbrains.api
@@ -9,6 +9,41 @@ public final class com/arkivanov/decompose/extensions/compose/jetbrains/Subscrib
 	public static final fun subscribeAsState (Lcom/arkivanov/decompose/value/Value;Landroidx/compose/runtime/SnapshotMutationPolicy;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State;
 }
 
+public final class com/arkivanov/decompose/extensions/compose/jetbrains/pages/ComposableSingletons$PagesKt {
+	public static final field INSTANCE Lcom/arkivanov/decompose/extensions/compose/jetbrains/pages/ComposableSingletons$PagesKt;
+	public static field lambda-1 Lkotlin/jvm/functions/Function7;
+	public static field lambda-2 Lkotlin/jvm/functions/Function7;
+	public fun <init> ()V
+	public final fun getLambda-1$extensions_compose_jetbrains_release ()Lkotlin/jvm/functions/Function7;
+	public final fun getLambda-2$extensions_compose_jetbrains_release ()Lkotlin/jvm/functions/Function7;
+}
+
+public final class com/arkivanov/decompose/extensions/compose/jetbrains/pages/PagesKt {
+	public static final fun Pages (Lcom/arkivanov/decompose/router/pages/ChildPages;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lcom/arkivanov/decompose/extensions/compose/jetbrains/pages/PagesScrollAnimation;Lkotlin/jvm/functions/Function7;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V
+	public static final fun Pages (Lcom/arkivanov/decompose/value/Value;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lcom/arkivanov/decompose/extensions/compose/jetbrains/pages/PagesScrollAnimation;Lkotlin/jvm/functions/Function7;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V
+	public static final fun defaultHorizontalPager ()Lkotlin/jvm/functions/Function7;
+	public static final fun defaultVerticalPager ()Lkotlin/jvm/functions/Function7;
+}
+
+public abstract interface class com/arkivanov/decompose/extensions/compose/jetbrains/pages/PagesScrollAnimation {
+}
+
+public final class com/arkivanov/decompose/extensions/compose/jetbrains/pages/PagesScrollAnimation$Custom : com/arkivanov/decompose/extensions/compose/jetbrains/pages/PagesScrollAnimation {
+	public static final field $stable I
+	public fun <init> (Landroidx/compose/animation/core/AnimationSpec;)V
+	public final fun getSpec ()Landroidx/compose/animation/core/AnimationSpec;
+}
+
+public final class com/arkivanov/decompose/extensions/compose/jetbrains/pages/PagesScrollAnimation$Default : com/arkivanov/decompose/extensions/compose/jetbrains/pages/PagesScrollAnimation {
+	public static final field $stable I
+	public static final field INSTANCE Lcom/arkivanov/decompose/extensions/compose/jetbrains/pages/PagesScrollAnimation$Default;
+}
+
+public final class com/arkivanov/decompose/extensions/compose/jetbrains/pages/PagesScrollAnimation$Disabled : com/arkivanov/decompose/extensions/compose/jetbrains/pages/PagesScrollAnimation {
+	public static final field $stable I
+	public static final field INSTANCE Lcom/arkivanov/decompose/extensions/compose/jetbrains/pages/PagesScrollAnimation$Disabled;
+}
+
 public final class com/arkivanov/decompose/extensions/compose/jetbrains/stack/ChildrenKt {
 	public static final fun Children (Lcom/arkivanov/decompose/router/stack/ChildStack;Landroidx/compose/ui/Modifier;Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/StackAnimation;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V
 	public static final fun Children (Lcom/arkivanov/decompose/value/Value;Landroidx/compose/ui/Modifier;Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/StackAnimation;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V
diff --git a/extensions-compose-jetbrains/api/jvm/extensions-compose-jetbrains.api b/extensions-compose-jetbrains/api/jvm/extensions-compose-jetbrains.api
index de80312b..efe709aa 100644
--- a/extensions-compose-jetbrains/api/jvm/extensions-compose-jetbrains.api
+++ b/extensions-compose-jetbrains/api/jvm/extensions-compose-jetbrains.api
@@ -6,6 +6,41 @@ public final class com/arkivanov/decompose/extensions/compose/jetbrains/lifecycl
 	public static final fun LifecycleController (Lcom/arkivanov/essenty/lifecycle/LifecycleRegistry;Landroidx/compose/ui/window/WindowState;Landroidx/compose/runtime/Composer;I)V
 }
 
+public final class com/arkivanov/decompose/extensions/compose/jetbrains/pages/ComposableSingletons$PagesKt {
+	public static final field INSTANCE Lcom/arkivanov/decompose/extensions/compose/jetbrains/pages/ComposableSingletons$PagesKt;
+	public static field lambda-1 Lkotlin/jvm/functions/Function7;
+	public static field lambda-2 Lkotlin/jvm/functions/Function7;
+	public fun <init> ()V
+	public final fun getLambda-1$extensions_compose_jetbrains ()Lkotlin/jvm/functions/Function7;
+	public final fun getLambda-2$extensions_compose_jetbrains ()Lkotlin/jvm/functions/Function7;
+}
+
+public final class com/arkivanov/decompose/extensions/compose/jetbrains/pages/PagesKt {
+	public static final fun Pages (Lcom/arkivanov/decompose/router/pages/ChildPages;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lcom/arkivanov/decompose/extensions/compose/jetbrains/pages/PagesScrollAnimation;Lkotlin/jvm/functions/Function7;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V
+	public static final fun Pages (Lcom/arkivanov/decompose/value/Value;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lcom/arkivanov/decompose/extensions/compose/jetbrains/pages/PagesScrollAnimation;Lkotlin/jvm/functions/Function7;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V
+	public static final fun defaultHorizontalPager ()Lkotlin/jvm/functions/Function7;
+	public static final fun defaultVerticalPager ()Lkotlin/jvm/functions/Function7;
+}
+
+public abstract interface class com/arkivanov/decompose/extensions/compose/jetbrains/pages/PagesScrollAnimation {
+}
+
+public final class com/arkivanov/decompose/extensions/compose/jetbrains/pages/PagesScrollAnimation$Custom : com/arkivanov/decompose/extensions/compose/jetbrains/pages/PagesScrollAnimation {
+	public static final field $stable I
+	public fun <init> (Landroidx/compose/animation/core/AnimationSpec;)V
+	public final fun getSpec ()Landroidx/compose/animation/core/AnimationSpec;
+}
+
+public final class com/arkivanov/decompose/extensions/compose/jetbrains/pages/PagesScrollAnimation$Default : com/arkivanov/decompose/extensions/compose/jetbrains/pages/PagesScrollAnimation {
+	public static final field $stable I
+	public static final field INSTANCE Lcom/arkivanov/decompose/extensions/compose/jetbrains/pages/PagesScrollAnimation$Default;
+}
+
+public final class com/arkivanov/decompose/extensions/compose/jetbrains/pages/PagesScrollAnimation$Disabled : com/arkivanov/decompose/extensions/compose/jetbrains/pages/PagesScrollAnimation {
+	public static final field $stable I
+	public static final field INSTANCE Lcom/arkivanov/decompose/extensions/compose/jetbrains/pages/PagesScrollAnimation$Disabled;
+}
+
 public final class com/arkivanov/decompose/extensions/compose/jetbrains/stack/ChildrenKt {
 	public static final fun Children (Lcom/arkivanov/decompose/router/stack/ChildStack;Landroidx/compose/ui/Modifier;Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/StackAnimation;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V
 	public static final fun Children (Lcom/arkivanov/decompose/value/Value;Landroidx/compose/ui/Modifier;Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/StackAnimation;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V
diff --git a/extensions-compose-jetbrains/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/pages/Pages.kt b/extensions-compose-jetbrains/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/pages/Pages.kt
new file mode 100644
index 00000000..63fefa1b
--- /dev/null
+++ b/extensions-compose-jetbrains/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/pages/Pages.kt
@@ -0,0 +1,123 @@
+package com.arkivanov.decompose.extensions.compose.jetbrains.pages
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.PagerState
+import androidx.compose.foundation.pager.VerticalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Modifier
+import com.arkivanov.decompose.ExperimentalDecomposeApi
+import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState
+import com.arkivanov.decompose.router.pages.ChildPages
+import com.arkivanov.decompose.value.Value
+
+/**
+ * Displays a list of pages represented by [ChildPages].
+ */
+@ExperimentalFoundationApi
+@ExperimentalDecomposeApi
+@Composable
+fun <T : Any> Pages(
+    pages: Value<ChildPages<*, T>>,
+    onPageSelected: (index: Int) -> Unit,
+    modifier: Modifier = Modifier,
+    scrollAnimation: PagesScrollAnimation = PagesScrollAnimation.Disabled,
+    pager: Pager = defaultHorizontalPager(),
+    pageContent: @Composable (index: Int, page: T) -> Unit,
+) {
+    val state = pages.subscribeAsState()
+
+    Pages(
+        pages = state.value,
+        onPageSelected = onPageSelected,
+        modifier = modifier,
+        scrollAnimation = scrollAnimation,
+        pager = pager,
+        pageContent = pageContent,
+    )
+}
+
+/**
+ * Displays a list of pages represented by [ChildPages].
+ */
+@ExperimentalFoundationApi
+@ExperimentalDecomposeApi
+@Composable
+fun <T : Any> Pages(
+    pages: ChildPages<*, T>,
+    onPageSelected: (index: Int) -> Unit,
+    modifier: Modifier = Modifier,
+    scrollAnimation: PagesScrollAnimation = PagesScrollAnimation.Disabled,
+    pager: Pager = defaultHorizontalPager(),
+    pageContent: @Composable (index: Int, page: T) -> Unit,
+) {
+    val selectedIndex = pages.selectedIndex
+    val state = rememberPagerState(initialPage = selectedIndex)
+
+    LaunchedEffect(selectedIndex) {
+        if (state.currentPage != selectedIndex) {
+            when (scrollAnimation) {
+                is PagesScrollAnimation.Disabled -> state.scrollToPage(selectedIndex)
+                is PagesScrollAnimation.Default -> state.animateScrollToPage(page = selectedIndex)
+                is PagesScrollAnimation.Custom -> state.animateScrollToPage(page = selectedIndex, animationSpec = scrollAnimation.spec)
+            }
+        }
+    }
+
+    DisposableEffect(state.settledPage) {
+        onPageSelected(state.settledPage)
+        onDispose {}
+    }
+
+    val items = pages.items
+
+    pager(
+        items.size,
+        modifier,
+        state,
+        { items[it].configuration },
+    ) { pageIndex ->
+        items[pageIndex].instance?.also { page ->
+            pageContent(pageIndex, page)
+        }
+    }
+}
+
+@ExperimentalFoundationApi
+@ExperimentalDecomposeApi
+fun defaultHorizontalPager(): Pager =
+    { pageCount, modifier, state, key, pageContent ->
+        HorizontalPager(
+            pageCount = pageCount,
+            modifier = modifier,
+            state = state,
+            key = key,
+            pageContent = pageContent,
+        )
+    }
+
+@ExperimentalFoundationApi
+@ExperimentalDecomposeApi
+fun defaultVerticalPager(): Pager =
+    { pageCount, modifier, state, key, pageContent ->
+        VerticalPager(
+            pageCount = pageCount,
+            modifier = modifier,
+            state = state,
+            key = key,
+            pageContent = pageContent,
+        )
+    }
+
+@OptIn(ExperimentalFoundationApi::class)
+internal typealias Pager =
+    @Composable (
+        pageCount: Int,
+        Modifier,
+        PagerState,
+        key: (index: Int) -> Any,
+        pageContent: @Composable (index: Int) -> Unit,
+    ) -> Unit
diff --git a/extensions-compose-jetbrains/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/pages/PagesScrollAnimation.kt b/extensions-compose-jetbrains/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/pages/PagesScrollAnimation.kt
new file mode 100644
index 00000000..1d20c1aa
--- /dev/null
+++ b/extensions-compose-jetbrains/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/pages/PagesScrollAnimation.kt
@@ -0,0 +1,14 @@
+package com.arkivanov.decompose.extensions.compose.jetbrains.pages
+
+import androidx.compose.animation.core.AnimationSpec
+import androidx.compose.foundation.ExperimentalFoundationApi
+import com.arkivanov.decompose.ExperimentalDecomposeApi
+
+@ExperimentalFoundationApi
+@ExperimentalDecomposeApi
+sealed interface PagesScrollAnimation {
+
+    object Disabled : PagesScrollAnimation
+    object Default : PagesScrollAnimation
+    class Custom(val spec: AnimationSpec<Float>) : PagesScrollAnimation
+}
diff --git a/extensions-compose-jetpack/api/extensions-compose-jetpack.api b/extensions-compose-jetpack/api/extensions-compose-jetpack.api
index 7e837984..5504df4d 100644
--- a/extensions-compose-jetpack/api/extensions-compose-jetpack.api
+++ b/extensions-compose-jetpack/api/extensions-compose-jetpack.api
@@ -9,6 +9,41 @@ public final class com/arkivanov/decompose/extensions/compose/jetpack/SubscribeA
 	public static final fun subscribeAsState (Lcom/arkivanov/decompose/value/Value;Landroidx/compose/runtime/SnapshotMutationPolicy;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State;
 }
 
+public final class com/arkivanov/decompose/extensions/compose/jetpack/pages/ComposableSingletons$PagesKt {
+	public static final field INSTANCE Lcom/arkivanov/decompose/extensions/compose/jetpack/pages/ComposableSingletons$PagesKt;
+	public static field lambda-1 Lkotlin/jvm/functions/Function7;
+	public static field lambda-2 Lkotlin/jvm/functions/Function7;
+	public fun <init> ()V
+	public final fun getLambda-1$extensions_compose_jetpack_release ()Lkotlin/jvm/functions/Function7;
+	public final fun getLambda-2$extensions_compose_jetpack_release ()Lkotlin/jvm/functions/Function7;
+}
+
+public final class com/arkivanov/decompose/extensions/compose/jetpack/pages/PagesKt {
+	public static final fun Pages (Lcom/arkivanov/decompose/router/pages/ChildPages;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lcom/arkivanov/decompose/extensions/compose/jetpack/pages/PagesScrollAnimation;Lkotlin/jvm/functions/Function7;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V
+	public static final fun Pages (Lcom/arkivanov/decompose/value/Value;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lcom/arkivanov/decompose/extensions/compose/jetpack/pages/PagesScrollAnimation;Lkotlin/jvm/functions/Function7;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V
+	public static final fun defaultHorizontalPager ()Lkotlin/jvm/functions/Function7;
+	public static final fun defaultVerticalPager ()Lkotlin/jvm/functions/Function7;
+}
+
+public abstract interface class com/arkivanov/decompose/extensions/compose/jetpack/pages/PagesScrollAnimation {
+}
+
+public final class com/arkivanov/decompose/extensions/compose/jetpack/pages/PagesScrollAnimation$Custom : com/arkivanov/decompose/extensions/compose/jetpack/pages/PagesScrollAnimation {
+	public static final field $stable I
+	public fun <init> (Landroidx/compose/animation/core/AnimationSpec;)V
+	public final fun getSpec ()Landroidx/compose/animation/core/AnimationSpec;
+}
+
+public final class com/arkivanov/decompose/extensions/compose/jetpack/pages/PagesScrollAnimation$Default : com/arkivanov/decompose/extensions/compose/jetpack/pages/PagesScrollAnimation {
+	public static final field $stable I
+	public static final field INSTANCE Lcom/arkivanov/decompose/extensions/compose/jetpack/pages/PagesScrollAnimation$Default;
+}
+
+public final class com/arkivanov/decompose/extensions/compose/jetpack/pages/PagesScrollAnimation$Disabled : com/arkivanov/decompose/extensions/compose/jetpack/pages/PagesScrollAnimation {
+	public static final field $stable I
+	public static final field INSTANCE Lcom/arkivanov/decompose/extensions/compose/jetpack/pages/PagesScrollAnimation$Disabled;
+}
+
 public final class com/arkivanov/decompose/extensions/compose/jetpack/stack/ChildrenKt {
 	public static final fun Children (Lcom/arkivanov/decompose/router/stack/ChildStack;Landroidx/compose/ui/Modifier;Lcom/arkivanov/decompose/extensions/compose/jetpack/stack/animation/StackAnimation;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V
 	public static final fun Children (Lcom/arkivanov/decompose/value/Value;Landroidx/compose/ui/Modifier;Lcom/arkivanov/decompose/extensions/compose/jetpack/stack/animation/StackAnimation;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V
diff --git a/extensions-compose-jetpack/src/main/java/com/arkivanov/decompose/extensions/compose/jetpack/pages/Pages.kt b/extensions-compose-jetpack/src/main/java/com/arkivanov/decompose/extensions/compose/jetpack/pages/Pages.kt
new file mode 100644
index 00000000..860345f8
--- /dev/null
+++ b/extensions-compose-jetpack/src/main/java/com/arkivanov/decompose/extensions/compose/jetpack/pages/Pages.kt
@@ -0,0 +1,123 @@
+package com.arkivanov.decompose.extensions.compose.jetpack.pages
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.PagerState
+import androidx.compose.foundation.pager.VerticalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Modifier
+import com.arkivanov.decompose.ExperimentalDecomposeApi
+import com.arkivanov.decompose.extensions.compose.jetpack.subscribeAsState
+import com.arkivanov.decompose.router.pages.ChildPages
+import com.arkivanov.decompose.value.Value
+
+/**
+ * Displays a list of pages represented by [ChildPages].
+ */
+@ExperimentalFoundationApi
+@ExperimentalDecomposeApi
+@Composable
+fun <T : Any> Pages(
+    pages: Value<ChildPages<*, T>>,
+    onPageSelected: (index: Int) -> Unit,
+    modifier: Modifier = Modifier,
+    scrollAnimation: PagesScrollAnimation = PagesScrollAnimation.Disabled,
+    pager: Pager = defaultHorizontalPager(),
+    pageContent: @Composable (index: Int, page: T) -> Unit,
+) {
+    val state = pages.subscribeAsState()
+
+    Pages(
+        pages = state.value,
+        onPageSelected = onPageSelected,
+        modifier = modifier,
+        scrollAnimation = scrollAnimation,
+        pager = pager,
+        pageContent = pageContent,
+    )
+}
+
+/**
+ * Displays a list of pages represented by [ChildPages].
+ */
+@ExperimentalFoundationApi
+@ExperimentalDecomposeApi
+@Composable
+fun <T : Any> Pages(
+    pages: ChildPages<*, T>,
+    onPageSelected: (index: Int) -> Unit,
+    modifier: Modifier = Modifier,
+    scrollAnimation: PagesScrollAnimation = PagesScrollAnimation.Disabled,
+    pager: Pager = defaultHorizontalPager(),
+    pageContent: @Composable (index: Int, page: T) -> Unit,
+) {
+    val selectedIndex = pages.selectedIndex
+    val state = rememberPagerState(initialPage = selectedIndex)
+
+    LaunchedEffect(selectedIndex) {
+        if (state.currentPage != selectedIndex) {
+            when (scrollAnimation) {
+                is PagesScrollAnimation.Disabled -> state.scrollToPage(selectedIndex)
+                is PagesScrollAnimation.Default -> state.animateScrollToPage(page = selectedIndex)
+                is PagesScrollAnimation.Custom -> state.animateScrollToPage(page = selectedIndex, animationSpec = scrollAnimation.spec)
+            }
+        }
+    }
+
+    DisposableEffect(state.settledPage) {
+        onPageSelected(state.settledPage)
+        onDispose {}
+    }
+
+    val items = pages.items
+
+    pager(
+        items.size,
+        modifier,
+        state,
+        { items[it].configuration },
+    ) { pageIndex ->
+        items[pageIndex].instance?.also { page ->
+            pageContent(pageIndex, page)
+        }
+    }
+}
+
+@ExperimentalFoundationApi
+@ExperimentalDecomposeApi
+fun defaultHorizontalPager(): Pager =
+    { pageCount, modifier, state, key, pageContent ->
+        HorizontalPager(
+            pageCount = pageCount,
+            modifier = modifier,
+            state = state,
+            key = key,
+            pageContent = pageContent,
+        )
+    }
+
+@ExperimentalFoundationApi
+@ExperimentalDecomposeApi
+fun defaultVerticalPager(): Pager =
+    { pageCount, modifier, state, key, pageContent ->
+        VerticalPager(
+            pageCount = pageCount,
+            modifier = modifier,
+            state = state,
+            key = key,
+            pageContent = pageContent,
+        )
+    }
+
+@OptIn(ExperimentalFoundationApi::class)
+internal typealias Pager =
+    @Composable (
+        pageCount: Int,
+        Modifier,
+        PagerState,
+        key: (index: Int) -> Any,
+        pageContent: @Composable (index: Int) -> Unit,
+    ) -> Unit
diff --git a/extensions-compose-jetpack/src/main/java/com/arkivanov/decompose/extensions/compose/jetpack/pages/PagesScrollAnimation.kt b/extensions-compose-jetpack/src/main/java/com/arkivanov/decompose/extensions/compose/jetpack/pages/PagesScrollAnimation.kt
new file mode 100644
index 00000000..9f81aab2
--- /dev/null
+++ b/extensions-compose-jetpack/src/main/java/com/arkivanov/decompose/extensions/compose/jetpack/pages/PagesScrollAnimation.kt
@@ -0,0 +1,14 @@
+package com.arkivanov.decompose.extensions.compose.jetpack.pages
+
+import androidx.compose.animation.core.AnimationSpec
+import androidx.compose.foundation.ExperimentalFoundationApi
+import com.arkivanov.decompose.ExperimentalDecomposeApi
+
+@ExperimentalFoundationApi
+@ExperimentalDecomposeApi
+sealed interface PagesScrollAnimation {
+
+    object Disabled : PagesScrollAnimation
+    object Default : PagesScrollAnimation
+    class Custom(val spec: AnimationSpec<Float>) : PagesScrollAnimation
+}


Test patch:
diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/BaseChildPagesTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/BaseChildPagesTest.kt
new file mode 100644
index 00000000..fa2b29d1
--- /dev/null
+++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/BaseChildPagesTest.kt
@@ -0,0 +1,81 @@
+package com.arkivanov.decompose.router.pages
+
+import com.arkivanov.decompose.Child
+import com.arkivanov.decompose.ComponentContext
+import com.arkivanov.decompose.value.Value
+import com.arkivanov.essenty.lifecycle.Lifecycle
+import com.arkivanov.essenty.parcelable.Parcelable
+import com.arkivanov.essenty.parcelable.Parcelize
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertTrue
+
+abstract class BaseChildPagesTest {
+
+    protected val navigation: PagesNavigation<Config> = PagesNavigation()
+
+    protected fun ComponentContext.childPages(
+        initialPages: Pages<Config> = Pages(),
+        persistent: Boolean = true,
+    ): Value<ChildPages<Config, Component>> =
+        childPages(
+            source = navigation,
+            initialPages = { initialPages },
+            persistent = persistent,
+            handleBackButton = true,
+            childFactory = ::Component,
+        )
+
+    protected fun configs(vararg ids: Int): List<Config> =
+        ids.map { Config(id = it) }
+
+    protected fun ChildPages<Config, Component>.assertPages(selectedIndex: Int, size: Int) {
+        assertPages(
+            ids = List(size) { it },
+            selectedIndex = selectedIndex,
+        )
+    }
+
+    protected fun ChildPages<Config, Component>.assertPages(ids: List<Int>, selectedIndex: Int) {
+        val pages =
+            ids.mapIndexed { index, id ->
+                id to id.takeIf { index in (selectedIndex - 1)..(selectedIndex + 1) }
+            }
+
+        assertContentEquals(pages, items.map { it.configuration.id to it.instance?.id })
+        assertEquals(selectedIndex, selectedIndex)
+
+        items.forEachIndexed { index, item ->
+            when (index) {
+                selectedIndex -> {
+                    assertIs<Child.Created<Config, Component>>(item)
+                    assertEquals(Lifecycle.State.RESUMED, item.instance.lifecycle.state)
+                }
+
+                in (selectedIndex - 1)..(selectedIndex + 1) -> {
+                    assertIs<Child.Created<Config, Component>>(item)
+                    assertEquals(Lifecycle.State.CREATED, item.instance.lifecycle.state)
+                }
+
+                else -> assertIs<Child.Destroyed<Config>>(item, "Index: $index")
+            }
+        }
+    }
+
+    protected fun ChildPages<Config, Component>.assertPagesEmpty() {
+        assertTrue(items.isEmpty())
+    }
+
+    @Parcelize
+    protected data class Config(
+        val id: Int,
+    ) : Parcelable
+
+    protected class Component(
+        config: Config,
+        componentContext: ComponentContext,
+    ) : ComponentContext by componentContext {
+        val id: Int = config.id
+    }
+}
diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesBackButtonTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesBackButtonTest.kt
new file mode 100644
index 00000000..5ec925df
--- /dev/null
+++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesBackButtonTest.kt
@@ -0,0 +1,47 @@
+package com.arkivanov.decompose.router.pages
+
+import com.arkivanov.decompose.DefaultComponentContext
+import com.arkivanov.decompose.value.getValue
+import com.arkivanov.essenty.backhandler.BackDispatcher
+import com.arkivanov.essenty.lifecycle.LifecycleRegistry
+import com.arkivanov.essenty.lifecycle.resume
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+@Suppress("TestFunctionName")
+class ChildPagesBackButtonTest : BaseChildPagesTest() {
+
+    private val lifecycle = LifecycleRegistry()
+    private val backDispatcher = BackDispatcher()
+    private val context = DefaultComponentContext(lifecycle = lifecycle, backHandler = backDispatcher)
+
+    @BeforeTest
+    fun before() {
+        lifecycle.resume()
+    }
+
+    @Test
+    fun WHEN_5_pages_and_selected_1_THEN_isEnabled_true() {
+        context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 1))
+
+        assertTrue(backDispatcher.isEnabled)
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_1_WHEN_back_THEN_selected_0() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 1))
+
+        backDispatcher.back()
+
+        pages.assertPages(selectedIndex = 0, size = 5)
+    }
+
+    @Test
+    fun WHEN_5_pages_and_selected_0_THEN_isEnabled_false() {
+        context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 0))
+
+        assertFalse(backDispatcher.isEnabled)
+    }
+}
diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesClearTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesClearTest.kt
new file mode 100644
index 00000000..7cc3e389
--- /dev/null
+++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesClearTest.kt
@@ -0,0 +1,56 @@
+package com.arkivanov.decompose.router.pages
+
+import com.arkivanov.decompose.DefaultComponentContext
+import com.arkivanov.decompose.value.getValue
+import com.arkivanov.essenty.lifecycle.LifecycleRegistry
+import com.arkivanov.essenty.lifecycle.resume
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+
+@Suppress("TestFunctionName")
+class ChildPagesClearTest : BaseChildPagesTest() {
+
+    private val lifecycle = LifecycleRegistry()
+    private val context = DefaultComponentContext(lifecycle = lifecycle)
+
+    @BeforeTest
+    fun before() {
+        lifecycle.resume()
+    }
+
+    @Test
+    fun GIVEN_pages_empty_WHEN_clear_THEN_pages_empty() {
+        val pages by context.childPages(initialPages = Pages())
+
+        navigation.clear()
+
+        pages.assertPagesEmpty()
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_0_WHEN_clear_THEN_pages_empty() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 0))
+
+        navigation.clear()
+
+        pages.assertPagesEmpty()
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_2_WHEN_clear_THEN_pages_empty() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 2))
+
+        navigation.clear()
+
+        pages.assertPagesEmpty()
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_4_WHEN_clear_THEN_pages_empty() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 4))
+
+        navigation.clear()
+
+        pages.assertPagesEmpty()
+    }
+}
diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesIntegrationTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesIntegrationTest.kt
new file mode 100644
index 00000000..186cac6c
--- /dev/null
+++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesIntegrationTest.kt
@@ -0,0 +1,84 @@
+package com.arkivanov.decompose.router.pages
+
+import com.arkivanov.decompose.ComponentContext
+import com.arkivanov.decompose.DefaultComponentContext
+import com.arkivanov.decompose.statekeeper.TestStateKeeperDispatcher
+import com.arkivanov.decompose.value.Value
+import com.arkivanov.decompose.value.getValue
+import com.arkivanov.essenty.backhandler.BackDispatcher
+import com.arkivanov.essenty.instancekeeper.InstanceKeeperDispatcher
+import com.arkivanov.essenty.lifecycle.LifecycleRegistry
+import com.arkivanov.essenty.lifecycle.resume
+import com.arkivanov.essenty.parcelable.Parcelable
+import com.arkivanov.essenty.parcelable.Parcelize
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+@Suppress("TestFunctionName")
+class ChildPagesIntegrationTest {
+
+    private val navigation = PagesNavigation<Config>()
+    private val lifecycle = LifecycleRegistry()
+    private val stateKeeper = TestStateKeeperDispatcher()
+    private val instanceKeeper = InstanceKeeperDispatcher()
+    private val backDispatcher = BackDispatcher()
+
+    private val context =
+        DefaultComponentContext(
+            lifecycle = lifecycle,
+            stateKeeper = stateKeeper,
+            instanceKeeper = instanceKeeper,
+            backHandler = backDispatcher,
+        )
+
+    @BeforeTest
+    fun before() {
+        lifecycle.resume()
+    }
+
+    @Test
+    fun GIVEN_three_pages_and_last_selected_WHEN_selectNext_circular_THEN_first_selected() {
+        val pages by context.childPages(initialPages = Pages(items = configs(1, 2, 3), selectedIndex = 2))
+
+        navigation.selectNext(circular = true)
+
+        assertEquals(0, pages.selectedIndex)
+    }
+
+    @Test
+    fun GIVEN_three_pages_and_first_selected_WHEN_selectNext_circular_THEN_first_selected() {
+        val pages by context.childPages(initialPages = Pages(items = configs(1, 2, 3), selectedIndex = 2))
+
+        navigation.selectNext(circular = true)
+
+        assertEquals(0, pages.selectedIndex)
+    }
+
+    private fun ComponentContext.childPages(
+        initialPages: Pages<Config> = Pages(),
+        persistent: Boolean = true,
+    ): Value<ChildPages<Config, Component>> =
+        childPages(
+            source = navigation,
+            initialPages = { initialPages },
+            persistent = persistent,
+            handleBackButton = true,
+            childFactory = ::Component,
+        )
+
+    private fun configs(vararg ids: Int): List<Config> =
+        ids.map { Config(id = it) }
+
+    @Parcelize
+    private data class Config(
+        val id: Int,
+    ) : Parcelable
+
+    private class Component(
+        config: Config,
+        componentContext: ComponentContext,
+    ) : ComponentContext by componentContext {
+        val id: Int = config.id
+    }
+}
diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesSavedStateTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesSavedStateTest.kt
new file mode 100644
index 00000000..1c09fc07
--- /dev/null
+++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesSavedStateTest.kt
@@ -0,0 +1,54 @@
+package com.arkivanov.decompose.router.pages
+
+import com.arkivanov.decompose.DefaultComponentContext
+import com.arkivanov.decompose.value.getValue
+import com.arkivanov.essenty.lifecycle.LifecycleRegistry
+import com.arkivanov.essenty.lifecycle.resume
+import com.arkivanov.essenty.statekeeper.StateKeeperDispatcher
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+
+@Suppress("TestFunctionName")
+class ChildPagesSavedStateTest : BaseChildPagesTest() {
+
+    private val lifecycle = LifecycleRegistry()
+
+    @BeforeTest
+    fun before() {
+        lifecycle.resume()
+    }
+
+    @Test
+    fun GIVEN_persistent_WHEN_recreated_THEN_state_restored() {
+        var stateKeeper = StateKeeperDispatcher()
+        var context = DefaultComponentContext(lifecycle = lifecycle, stateKeeper = stateKeeper)
+        context.childPages(
+            initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 2),
+            persistent = true,
+        )
+
+        val savedState = stateKeeper.save()
+        stateKeeper = StateKeeperDispatcher(savedState = savedState)
+        context = DefaultComponentContext(lifecycle = lifecycle, stateKeeper = stateKeeper)
+        val pages2 by context.childPages()
+
+        pages2.assertPages(selectedIndex = 2, size = 5)
+    }
+
+    @Test
+    fun GIVEN_not_persistent_WHEN_recreated_THEN_initial_state_applied() {
+        var stateKeeper = StateKeeperDispatcher()
+        var context = DefaultComponentContext(lifecycle = lifecycle, stateKeeper = stateKeeper)
+        context.childPages(
+            initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 2),
+            persistent = false,
+        )
+
+        val savedState = stateKeeper.save()
+        stateKeeper = StateKeeperDispatcher(savedState = savedState)
+        context = DefaultComponentContext(lifecycle = lifecycle, stateKeeper = stateKeeper)
+        val pages2 by context.childPages(initialPages = Pages(items = configs(5, 6, 7), selectedIndex = 0))
+
+        pages2.assertPages(ids = listOf(5, 6, 7), selectedIndex = 0)
+    }
+}
diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesSelectFirstTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesSelectFirstTest.kt
new file mode 100644
index 00000000..dda052dd
--- /dev/null
+++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesSelectFirstTest.kt
@@ -0,0 +1,56 @@
+package com.arkivanov.decompose.router.pages
+
+import com.arkivanov.decompose.DefaultComponentContext
+import com.arkivanov.decompose.value.getValue
+import com.arkivanov.essenty.lifecycle.LifecycleRegistry
+import com.arkivanov.essenty.lifecycle.resume
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+
+@Suppress("TestFunctionName")
+class ChildPagesSelectFirstTest : BaseChildPagesTest() {
+
+    private val lifecycle = LifecycleRegistry()
+    private val context = DefaultComponentContext(lifecycle = lifecycle)
+
+    @BeforeTest
+    fun before() {
+        lifecycle.resume()
+    }
+
+    @Test
+    fun GIVEN_pages_empty_WHEN_selectFirst_THEN_pages_empty() {
+        val pages by context.childPages(initialPages = Pages())
+
+        navigation.selectFirst()
+
+        pages.assertPagesEmpty()
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_0_WHEN_selectFirst_THEN_selected_0() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 0))
+
+        navigation.selectFirst()
+
+        pages.assertPages(selectedIndex = 0, size = 5)
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_2_WHEN_selectFirst_THEN_selected_0() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 2))
+
+        navigation.selectFirst()
+
+        pages.assertPages(selectedIndex = 0, size = 5)
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_4_WHEN_selectFirst_THEN_selected_0() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 4))
+
+        navigation.selectFirst()
+
+        pages.assertPages(selectedIndex = 0, size = 5)
+    }
+}
diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesSelectLastTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesSelectLastTest.kt
new file mode 100644
index 00000000..ec515ed6
--- /dev/null
+++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesSelectLastTest.kt
@@ -0,0 +1,56 @@
+package com.arkivanov.decompose.router.pages
+
+import com.arkivanov.decompose.DefaultComponentContext
+import com.arkivanov.decompose.value.getValue
+import com.arkivanov.essenty.lifecycle.LifecycleRegistry
+import com.arkivanov.essenty.lifecycle.resume
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+
+@Suppress("TestFunctionName")
+class ChildPagesSelectLastTest : BaseChildPagesTest() {
+
+    private val lifecycle = LifecycleRegistry()
+    private val context = DefaultComponentContext(lifecycle = lifecycle)
+
+    @BeforeTest
+    fun before() {
+        lifecycle.resume()
+    }
+
+    @Test
+    fun GIVEN_pages_empty_WHEN_selectLast_THEN_pages_empty() {
+        val pages by context.childPages(initialPages = Pages())
+
+        navigation.selectLast()
+
+        pages.assertPagesEmpty()
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_0_WHEN_selectLast_THEN_selected_0() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 0))
+
+        navigation.selectLast()
+
+        pages.assertPages(selectedIndex = 4, size = 5)
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_2_WHEN_selectLast_THEN_selected_0() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 2))
+
+        navigation.selectLast()
+
+        pages.assertPages(selectedIndex = 4, size = 5)
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_4_WHEN_selectLast_THEN_selected_0() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 4))
+
+        navigation.selectLast()
+
+        pages.assertPages(selectedIndex = 4, size = 5)
+    }
+}
diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesSelectNextTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesSelectNextTest.kt
new file mode 100644
index 00000000..03369365
--- /dev/null
+++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesSelectNextTest.kt
@@ -0,0 +1,83 @@
+package com.arkivanov.decompose.router.pages
+
+import com.arkivanov.decompose.DefaultComponentContext
+import com.arkivanov.decompose.value.getValue
+import com.arkivanov.essenty.lifecycle.LifecycleRegistry
+import com.arkivanov.essenty.lifecycle.resume
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+
+@Suppress("TestFunctionName")
+class ChildPagesSelectNextTest : BaseChildPagesTest() {
+
+    private val lifecycle = LifecycleRegistry()
+    private val context = DefaultComponentContext(lifecycle = lifecycle)
+
+    @BeforeTest
+    fun before() {
+        lifecycle.resume()
+    }
+
+    @Test
+    fun GIVEN_pages_empty_WHEN_selectNext_THEN_pages_empty() {
+        val pages by context.childPages(initialPages = Pages())
+
+        navigation.selectNext(circular = false)
+
+        pages.assertPagesEmpty()
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_0_WHEN_selectNext_THEN_selected_1() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 0))
+
+        navigation.selectNext(circular = false)
+
+        pages.assertPages(selectedIndex = 1, size = 5)
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_0_WHEN_selectNext_circular_THEN_selected_1() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 0))
+
+        navigation.selectNext(circular = true)
+
+        pages.assertPages(selectedIndex = 1, size = 5)
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_1_WHEN_selectNext_THEN_selected_2() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 1))
+
+        navigation.selectNext(circular = false)
+
+        pages.assertPages(selectedIndex = 2, size = 5)
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_1_WHEN_selectNext_circular_THEN_selected_2() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 1))
+
+        navigation.selectNext(circular = true)
+
+        pages.assertPages(selectedIndex = 2, size = 5)
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_4_WHEN_selectNext_THEN_selected_4() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 4))
+
+        navigation.selectNext(circular = false)
+
+        pages.assertPages(selectedIndex = 4, size = 5)
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_4_WHEN_selectNext_circular_THEN_selected_0() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 4))
+
+        navigation.selectNext(circular = true)
+
+        pages.assertPages(selectedIndex = 0, size = 5)
+    }
+}
diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesSelectPrevTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesSelectPrevTest.kt
new file mode 100644
index 00000000..3a439622
--- /dev/null
+++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesSelectPrevTest.kt
@@ -0,0 +1,83 @@
+package com.arkivanov.decompose.router.pages
+
+import com.arkivanov.decompose.DefaultComponentContext
+import com.arkivanov.decompose.value.getValue
+import com.arkivanov.essenty.lifecycle.LifecycleRegistry
+import com.arkivanov.essenty.lifecycle.resume
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+
+@Suppress("TestFunctionName")
+class ChildPagesSelectPrevTest : BaseChildPagesTest() {
+
+    private val lifecycle = LifecycleRegistry()
+    private val context = DefaultComponentContext(lifecycle = lifecycle)
+
+    @BeforeTest
+    fun before() {
+        lifecycle.resume()
+    }
+
+    @Test
+    fun GIVEN_pages_empty_WHEN_selectPrev_THEN_pages_empty() {
+        val pages by context.childPages(initialPages = Pages())
+
+        navigation.selectPrev(circular = false)
+
+        pages.assertPagesEmpty()
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_4_WHEN_selectPrev_THEN_selected_3() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 4))
+
+        navigation.selectPrev(circular = false)
+
+        pages.assertPages(selectedIndex = 3, size = 5)
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_4_WHEN_selectPrev_circular_THEN_selected_3() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 4))
+
+        navigation.selectPrev(circular = true)
+
+        pages.assertPages(selectedIndex = 3, size = 5)
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_3_WHEN_selectPrev_THEN_selected_2() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 3))
+
+        navigation.selectPrev(circular = false)
+
+        pages.assertPages(selectedIndex = 2, size = 5)
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_3_WHEN_selectPrev_circular_THEN_selected_2() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 3))
+
+        navigation.selectPrev(circular = true)
+
+        pages.assertPages(selectedIndex = 2, size = 5)
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_0_WHEN_selectPrev_THEN_selected_0() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 0))
+
+        navigation.selectPrev(circular = false)
+
+        pages.assertPages(selectedIndex = 0, size = 5)
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_0_WHEN_selectPrev_circular_THEN_selected_4() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 0))
+
+        navigation.selectPrev(circular = true)
+
+        pages.assertPages(selectedIndex = 4, size = 5)
+    }
+}
diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesSelectTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesSelectTest.kt
new file mode 100644
index 00000000..d02f3302
--- /dev/null
+++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesSelectTest.kt
@@ -0,0 +1,83 @@
+package com.arkivanov.decompose.router.pages
+
+import com.arkivanov.decompose.DefaultComponentContext
+import com.arkivanov.decompose.value.getValue
+import com.arkivanov.essenty.lifecycle.LifecycleRegistry
+import com.arkivanov.essenty.lifecycle.resume
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+
+@Suppress("TestFunctionName")
+class ChildPagesSelectTest : BaseChildPagesTest() {
+
+    private val lifecycle = LifecycleRegistry()
+    private val context = DefaultComponentContext(lifecycle = lifecycle)
+
+    @BeforeTest
+    fun before() {
+        lifecycle.resume()
+    }
+
+    @Test
+    fun GIVEN_pages_empty_WHEN_select_THEN_pages_empty() {
+        val pages by context.childPages(initialPages = Pages())
+
+        navigation.select(index = 0)
+
+        pages.assertPagesEmpty()
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_0_WHEN_select_0_THEN_selected_0() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 0))
+
+        navigation.select(index = 0)
+
+        pages.assertPages(selectedIndex = 0, size = 5)
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_0_WHEN_select_2_THEN_selected_2() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 0))
+
+        navigation.select(index = 2)
+
+        pages.assertPages(selectedIndex = 2, size = 5)
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_0_WHEN_select_4_THEN_selected_4() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 0))
+
+        navigation.select(index = 4)
+
+        pages.assertPages(selectedIndex = 4, size = 5)
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_4_WHEN_select_0_THEN_selected_0() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 4))
+
+        navigation.select(index = 0)
+
+        pages.assertPages(selectedIndex = 0, size = 5)
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_4_WHEN_select_2_THEN_selected_2() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 4))
+
+        navigation.select(index = 2)
+
+        pages.assertPages(selectedIndex = 2, size = 5)
+    }
+
+    @Test
+    fun GIVEN_5_pages_and_selected_4_WHEN_select_4_THEN_selected_4() {
+        val pages by context.childPages(initialPages = Pages(items = configs(0, 1, 2, 3, 4), selectedIndex = 4))
+
+        navigation.select(index = 4)
+
+        pages.assertPages(selectedIndex = 4, size = 5)
+    }
+}