diff --git a/decompose/api/android/decompose.api b/decompose/api/android/decompose.api index 53eec1953..00777a28e 100644 --- a/decompose/api/android/decompose.api +++ b/decompose/api/android/decompose.api @@ -76,15 +76,21 @@ public abstract interface class com/arkivanov/decompose/router/Router { } public final class com/arkivanov/decompose/router/RouterExtKt { - public static final fun bringToFront (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;)V + public static final synthetic fun bringToFront (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;)V + public static final fun bringToFront (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)V + public static synthetic fun bringToFront$default (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V public static final fun getActiveChild (Lcom/arkivanov/decompose/router/Router;)Lcom/arkivanov/decompose/Child$Created; public static final fun navigate (Lcom/arkivanov/decompose/router/Router;Lkotlin/jvm/functions/Function1;)V public static final fun pop (Lcom/arkivanov/decompose/router/Router;Lkotlin/jvm/functions/Function1;)V public static synthetic fun pop$default (Lcom/arkivanov/decompose/router/Router;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V public static final fun popWhile (Lcom/arkivanov/decompose/router/Router;Lkotlin/jvm/functions/Function1;)V public static final fun popWhile (Lcom/arkivanov/decompose/router/Router;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V - public static final fun push (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;)V - public static final fun replaceCurrent (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;)V + public static final synthetic fun push (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;)V + public static final fun push (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)V + public static synthetic fun push$default (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V + public static final synthetic fun replaceCurrent (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;)V + public static final fun replaceCurrent (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)V + public static synthetic fun replaceCurrent$default (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V } public final class com/arkivanov/decompose/router/RouterFactoryExtKt { diff --git a/decompose/api/jvm/decompose.api b/decompose/api/jvm/decompose.api index 2e2f40a07..53ad18586 100644 --- a/decompose/api/jvm/decompose.api +++ b/decompose/api/jvm/decompose.api @@ -63,15 +63,21 @@ public abstract interface class com/arkivanov/decompose/router/Router { } public final class com/arkivanov/decompose/router/RouterExtKt { - public static final fun bringToFront (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;)V + public static final synthetic fun bringToFront (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;)V + public static final fun bringToFront (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)V + public static synthetic fun bringToFront$default (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V public static final fun getActiveChild (Lcom/arkivanov/decompose/router/Router;)Lcom/arkivanov/decompose/Child$Created; public static final fun navigate (Lcom/arkivanov/decompose/router/Router;Lkotlin/jvm/functions/Function1;)V public static final fun pop (Lcom/arkivanov/decompose/router/Router;Lkotlin/jvm/functions/Function1;)V public static synthetic fun pop$default (Lcom/arkivanov/decompose/router/Router;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V public static final fun popWhile (Lcom/arkivanov/decompose/router/Router;Lkotlin/jvm/functions/Function1;)V public static final fun popWhile (Lcom/arkivanov/decompose/router/Router;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V - public static final fun push (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;)V - public static final fun replaceCurrent (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;)V + public static final synthetic fun push (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;)V + public static final fun push (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)V + public static synthetic fun push$default (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V + public static final synthetic fun replaceCurrent (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;)V + public static final fun replaceCurrent (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)V + public static synthetic fun replaceCurrent$default (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V } public final class com/arkivanov/decompose/router/RouterFactoryExtKt { diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/RouterExt.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/RouterExt.kt index dbab31a73..b15da0979 100644 --- a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/RouterExt.kt +++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/RouterExt.kt @@ -9,11 +9,18 @@ fun Router.navigate(transformer: (stack: List) -> List) { navigate(transformer = transformer, onComplete = { _, _ -> }) } +@Deprecated(message = "For binary compatibility", level = DeprecationLevel.HIDDEN) +fun Router.push(configuration: C) { + push(configuration = configuration, onComplete = {}) +} + /** - * Pushes the provided [configuration] at the top of the stack.. + * Pushes the provided [configuration] at the top of the stack. + * + * @param onComplete called when the navigation is finished (either synchronously or asynchronously). */ -fun Router.push(configuration: C) { - navigate { it + configuration } +fun Router.push(configuration: C, onComplete: () -> Unit = {}) { + navigate(transformer = { it + configuration }, onComplete = { _, _ -> onComplete() }) } /** @@ -26,20 +33,15 @@ fun Router.push(configuration: C) { fun Router.pop(onComplete: (isSuccess: Boolean) -> Unit = {}) { navigate( transformer = { stack -> stack.takeIf { it.size > 1 }?.dropLast(1) ?: stack }, - onComplete = { newStack, oldStack -> onComplete(newStack.size < oldStack.size) } + onComplete = { newStack, oldStack -> onComplete(newStack.size < oldStack.size) }, ) } /** - * Drops the configurations at the top of the stack while the [predicate] returns true + * Drops the configurations at the top of the stack while the [predicate] returns `true`. */ -inline fun Router.popWhile( - crossinline predicate: (C) -> Boolean -) { - popWhile( - predicate = predicate, - onComplete = {} - ) +inline fun Router.popWhile(crossinline predicate: (C) -> Boolean) { + popWhile(predicate = predicate, onComplete = {}) } /** @@ -50,29 +52,45 @@ inline fun Router.popWhile( */ inline fun Router.popWhile( crossinline predicate: (C) -> Boolean, - crossinline onComplete: (isSuccess: Boolean) -> Unit + crossinline onComplete: (isSuccess: Boolean) -> Unit, ) { navigate( transformer = { stack -> stack.dropLastWhile(predicate) }, - onComplete = { newStack, oldStack -> onComplete(newStack.size < oldStack.size) } + onComplete = { newStack, oldStack -> onComplete(newStack.size < oldStack.size) }, ) } +@Deprecated(message = "For binary compatibility", level = DeprecationLevel.HIDDEN) +fun Router.replaceCurrent(configuration: C) { + replaceCurrent(configuration = configuration, onComplete = {}) +} + /** * Replaces the current configuration at the top of the stack with the provided [configuration]. + * + * @param onComplete called when the navigation is finished (either synchronously or asynchronously). */ -fun Router.replaceCurrent(configuration: C) { - navigate { it.dropLast(1) + configuration } +fun Router.replaceCurrent(configuration: C, onComplete: () -> Unit = {}) { + navigate( + transformer = { it.dropLast(1) + configuration }, + onComplete = { _, _ -> onComplete() }, + ) +} + +@Deprecated(message = "For binary compatibility", level = DeprecationLevel.HIDDEN) +fun Router.bringToFront(configuration: C) { + bringToFront(configuration = configuration, onComplete = {}) } /** * Removes all components with configurations of [configuration]'s class, and adds the provided [configuration] to the top of the stack. * The operation is performed as one transaction. If there is already a component with the same configuration, it will not be recreated. */ -fun Router.bringToFront(configuration: C) { - navigate { stack -> - stack.filterNot { it::class == configuration::class } + configuration - } +fun Router.bringToFront(configuration: C, onComplete: () -> Unit = {}) { + navigate( + transformer = { stack -> stack.filterNot { it::class == configuration::class } + configuration }, + onComplete = { _, _ -> onComplete() }, + ) } val Router.activeChild: Child.Created get() = state.value.activeChild diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/RouterPopWhileTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/RouterPopWhileTest.kt new file mode 100644 index 000000000..edd55965b --- /dev/null +++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/RouterPopWhileTest.kt @@ -0,0 +1,28 @@ +package com.arkivanov.decompose.router + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@Suppress("TestFunctionName") +class RouterPopWhileTest { + + @Test + fun WHEN_popWhile_THEN_popped() { + val router = TestRouter(listOf(1, 2, 3, 4)) + + router.popWhile { it != 2 } + + assertEquals(listOf(1, 2), router.stack) + } + + @Test + fun WHEN_popWhile_THEN_onComplete_called() { + val router = TestRouter(listOf(1, 2, 3, 4)) + var isCalled = false + + router.popWhile(predicate = { it != 2 }, onComplete = { isCalled = true }) + + assertTrue(isCalled) + } +} diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/RouterPushTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/RouterPushTest.kt new file mode 100644 index 000000000..d29a8a611 --- /dev/null +++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/RouterPushTest.kt @@ -0,0 +1,28 @@ +package com.arkivanov.decompose.router + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@Suppress("TestFunctionName") +class RouterPushTest { + + @Test + fun WHEN_push_THEN_pushed() { + val router = TestRouter(listOf(1, 2)) + + router.push(3) + + assertEquals(listOf(1, 2, 3), router.stack) + } + + @Test + fun WHEN_push_THEN_onComplete_called() { + val router = TestRouter(listOf(1, 2)) + var isCalled = false + + router.push(3) { isCalled = true } + + assertTrue(isCalled) + } +} diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/RouterReplaceCurrentTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/RouterReplaceCurrentTest.kt new file mode 100644 index 000000000..6008ae9f6 --- /dev/null +++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/RouterReplaceCurrentTest.kt @@ -0,0 +1,28 @@ +package com.arkivanov.decompose.router + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@Suppress("TestFunctionName") +class RouterReplaceCurrentTest { + + @Test + fun WHEN_replaceCurrent_THEN_last_configuration_replaced() { + val router = TestRouter(listOf(1, 2, 3)) + + router.replaceCurrent(4) + + assertEquals(listOf(1, 2, 4), router.stack) + } + + @Test + fun WHEN_replaceCurrent_THEN_onComplete_called() { + val router = TestRouter(listOf(1, 2, 3)) + var isCalled = false + + router.replaceCurrent(4) { isCalled = true } + + assertTrue(isCalled) + } +} diff --git a/decompose/src/jsMain/kotlin/com/arkivanov/decompose/router/webhistory/DefaultWebHistoryController.kt b/decompose/src/jsMain/kotlin/com/arkivanov/decompose/router/webhistory/DefaultWebHistoryController.kt index 4bea900d9..2ba38454a 100644 --- a/decompose/src/jsMain/kotlin/com/arkivanov/decompose/router/webhistory/DefaultWebHistoryController.kt +++ b/decompose/src/jsMain/kotlin/com/arkivanov/decompose/router/webhistory/DefaultWebHistoryController.kt @@ -11,16 +11,17 @@ import com.arkivanov.decompose.router.subscribe import org.w3c.dom.PopStateEvent @ExperimentalDecomposeApi -class DefaultWebHistoryController( +class DefaultWebHistoryController internal constructor( private val window: Window, ) : WebHistoryController { + @Suppress("unused") // Public API constructor() : this(WindowImpl()) override fun attach( router: Router, getPath: (configuration: C) -> String, - getConfiguration: (path: String) -> C + getConfiguration: (path: String) -> C, ) { val impl = Impl(router, window, getPath, getConfiguration) router.state.subscribe(impl::onStateChanged) @@ -118,90 +119,52 @@ class DefaultWebHistoryController( fun onPopState(event: PopStateEvent) { val newData = event.getData() ?: return - val stack = router.state.value.configurations() - val newConfigurationKey = newData.configurationKey - - val indexInStack = stack.indexOfLast { it.hashCode() == newConfigurationKey } - if (indexInStack >= 0) { - if (indexInStack < stack.lastIndex) { // History popped, pop from the Router - isStateObserverEnabled = false - router.navigate { stack.take(indexInStack + 1) } - isStateObserverEnabled = true + val newConfigurationKey = newData.last().configurationKey + + isStateObserverEnabled = false + + router.navigate { stack -> + val indexInStack = stack.indexOfLast { it.hashCode() == newConfigurationKey } + if (indexInStack >= 0) { + // History popped, pop from the Router + stack.take(indexInStack + 1) + } else { + // History pushed, push to the Router + stack + newData.drop(stack.size).map { getConfiguration(it.path) } } - } else { // History pushed, push to the Router - val nextPaths = getNextPaths(currentConfiguration = stack.last(), nextData = newData) - val nextConfigurations = nextPaths.map(getConfiguration) - isStateObserverEnabled = false - router.navigate { stack + nextConfigurations } - isStateObserverEnabled = true - } - } - - private fun getNextPaths(currentConfiguration: C, nextData: PageData): List { - val paths = ArrayList() - val currentConfigurationKey = currentConfiguration.hashCode() - var data: PageData? = nextData - - while ((data != null) && (data.configurationKey != currentConfigurationKey)) { - paths += data.path - data = data.prev } - return paths.asReversed() + isStateObserverEnabled = true } private fun History.pushState(configuration: C) { - val currentData: PageData? = window.history.getData() - - val nextData = - PageData( - configurationKey = configuration.hashCode(), - path = getPath(configuration), - prev = currentData, - ) - - currentData?.next = nextData - - pushState(data = nextData, url = nextData.path) + val nextItem = PageItem(configurationKey = configuration.hashCode(), path = getPath(configuration)) + val newData = getCurrentData() + nextItem + pushState(data = newData, url = nextItem.path) } private fun History.replaceState(configuration: C) { - val currentData: PageData? = window.history.getData() - val prevData: PageData? = currentData?.prev - val nextData: PageData? = currentData?.next - - val newData = - PageData( - configurationKey = configuration.hashCode(), - path = getPath(configuration), - prev = prevData, - next = nextData, - ) - - prevData?.next = nextData - nextData?.prev = newData - - replaceState(data = newData, url = newData.path) + val newItem = PageItem(configurationKey = configuration.hashCode(), path = getPath(configuration)) + val newData = getCurrentData().dropLast(1).toTypedArray() + newItem + replaceState(data = newData, url = newItem.path) } - private fun History.getData(): PageData? = state?.unsafeCast() + private fun getCurrentData(): Array = window.history.state?.unsafeCast>() ?: emptyArray() - private fun PopStateEvent.getData(): PageData? = state?.unsafeCast() + private fun PopStateEvent.getData(): Array? = state?.unsafeCast>() } - private data class PageData( + private data class PageItem( val configurationKey: Int, val path: String, - var prev: PageData? = null, - var next: PageData? = null, ) - interface Window { + internal interface Window { val history: History var onPopState: ((PopStateEvent) -> Unit)? } - interface History { + internal interface History { val state: Any? fun go(delta: Int) diff --git a/decompose/src/jsTest/kotlin/com/arkivanov/decompose/router/webhistory/DefaultWebHistoryControllerTest.kt b/decompose/src/jsTest/kotlin/com/arkivanov/decompose/router/webhistory/DefaultWebHistoryControllerTest.kt index 1e229e1c4..68a4ab977 100644 --- a/decompose/src/jsTest/kotlin/com/arkivanov/decompose/router/webhistory/DefaultWebHistoryControllerTest.kt +++ b/decompose/src/jsTest/kotlin/com/arkivanov/decompose/router/webhistory/DefaultWebHistoryControllerTest.kt @@ -5,6 +5,7 @@ import com.arkivanov.decompose.router.TestRouter import com.arkivanov.decompose.router.navigate import com.arkivanov.decompose.router.pop import com.arkivanov.decompose.router.push +import com.arkivanov.decompose.router.replaceCurrent import com.arkivanov.essenty.parcelable.Parcelable import kotlin.test.Test import kotlin.test.assertEquals @@ -338,6 +339,48 @@ class DefaultWebHistoryControllerTest { assertEquals(listOf(Config(0), Config(1)), router.stack) } + @Test + fun GIVEN_router_with_initial_stack_of_a_and_pushed_b_and_go_back_and_replace_current_with_c_WHEN_go_forward_THEN_history_is_c_b_and_b_is_active() { + if (isNode()) { + return + } + + val router = TestRouter(listOf(Config(0))) + attach(router) + router.push(Config(1)) + window.runPendingOperations() + window.history.go(delta = -1) + window.runPendingOperations() + router.replaceCurrent(Config(2)) + window.runPendingOperations() + + window.history.go(delta = 1) + window.runPendingOperations() + + window.history.assertStack(listOf("/2", "/1")) + } + + @Test + fun GIVEN_router_with_initial_stack_of_a_and_pushed_b_and_go_back_and_replace_current_with_c_WHEN_go_forward_THEN_stack_is_c_b() { + if (isNode()) { + return + } + + val router = TestRouter(listOf(Config(0))) + attach(router) + router.push(Config(1)) + window.runPendingOperations() + window.history.go(delta = -1) + window.runPendingOperations() + router.replaceCurrent(Config(2)) + window.runPendingOperations() + + window.history.go(delta = 1) + window.runPendingOperations() + + assertEquals(listOf(Config(2), Config(1)), router.stack) + } + @Test fun GIVEN_router_with_initial_stack_of_a_b_and_navigate_to_c_WHEN_go_forward_THEN_stack_is_c_b() { if (isNode()) { diff --git a/decompose/src/jsTest/kotlin/com/arkivanov/decompose/router/webhistory/TestHistory.kt b/decompose/src/jsTest/kotlin/com/arkivanov/decompose/router/webhistory/TestHistory.kt index 85e77dfce..df10b53f2 100644 --- a/decompose/src/jsTest/kotlin/com/arkivanov/decompose/router/webhistory/TestHistory.kt +++ b/decompose/src/jsTest/kotlin/com/arkivanov/decompose/router/webhistory/TestHistory.kt @@ -35,12 +35,12 @@ class TestHistory( stack.removeLast() } - stack += Entry(data = data, url = url) + stack += Entry(data = data?.serializeAndDeserialize(), url = url) index++ } override fun replaceState(data: Any?, url: String?) { - stack[index] = Entry(data = data, url = url) + stack[index] = Entry(data = data?.serializeAndDeserialize(), url = url) } fun assertStack(urls: List, index: Int = urls.lastIndex) { @@ -48,6 +48,8 @@ class TestHistory( assertEquals(index, this.index) } + private fun Any.serializeAndDeserialize(): Any = JSON.parse(JSON.stringify(this)) + class Entry( val data: Any? = null, val url: String? = null, diff --git a/deps.versions.toml b/deps.versions.toml index 692febaa1..4e6e0a740 100644 --- a/deps.versions.toml +++ b/deps.versions.toml @@ -1,6 +1,6 @@ [versions] -decompose = "0.6.0" +decompose = "0.7.0" kotlin = "1.6.21" essenty = "0.2.2" reaktive = "1.2.1"