Skip to content

Commit

Permalink
Merge pull request #15 from arkivanov/js-history
Browse files Browse the repository at this point in the history
Added WebHistoryController
  • Loading branch information
arkivanov committed Jan 14, 2022
2 parents 06d013a + 33c8804 commit 677e297
Show file tree
Hide file tree
Showing 12 changed files with 851 additions and 13 deletions.
4 changes: 4 additions & 0 deletions decompose/api/android/decompose.api
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ public final class com/arkivanov/decompose/router/RouterState {
public fun toString ()Ljava/lang/String;
}

public abstract interface class com/arkivanov/decompose/router/webhistory/WebHistoryController {
public abstract fun attach (Lcom/arkivanov/decompose/router/Router;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
}

public abstract class com/arkivanov/decompose/value/MutableValue : com/arkivanov/decompose/value/Value {
public fun <init> ()V
public abstract fun getValue ()Ljava/lang/Object;
Expand Down
4 changes: 4 additions & 0 deletions decompose/api/jvm/decompose.api
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ public final class com/arkivanov/decompose/router/RouterState {
public fun toString ()Ljava/lang/String;
}

public abstract interface class com/arkivanov/decompose/router/webhistory/WebHistoryController {
public abstract fun attach (Lcom/arkivanov/decompose/router/Router;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
}

public abstract class com/arkivanov/decompose/value/MutableValue : com/arkivanov/decompose/value/Value {
public fun <init> ()V
public abstract fun getValue ()Ljava/lang/Object;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.arkivanov.decompose.router.webhistory

import com.arkivanov.decompose.ExperimentalDecomposeApi
import com.arkivanov.decompose.router.Router

/**
* Connects the [Router] and the Web [History](https://developer.mozilla.org/en-US/docs/Web/API/History) API together.
*/
@ExperimentalDecomposeApi
interface WebHistoryController {


/**
* Listens for the [Router] state changes and updates the Web [History](https://developer.mozilla.org/en-US/docs/Web/API/History)
* accordingly. Also listens for the `History` changes and navigates the [Router].
*
* @param router a [Router] that should be observed and manipulated
* @param getPath a mapper from the [Router] configuration to a corresponding Web page path (starting from '/')
* @param getConfiguration a mapper from the Web page path (starting from '/') to a corresponding [Router] configuration
*/
fun <C : Any> attach(
router: Router<C, *>,
getPath: (configuration: C) -> String,
getConfiguration: (path: String) -> C
)
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,33 @@
package com.arkivanov.decompose.router

import com.arkivanov.decompose.value.Value
import com.arkivanov.decompose.Child
import com.arkivanov.decompose.value.MutableValue

class TestRouter<C : Any>(
var stack: List<C> = emptyList()
) : Router<C, Nothing> {
class TestRouter<C : Any>(stack: List<C>) : Router<C, Any> {

override val state: Value<RouterState<C, Nothing>> get() = TODO("Not yet implemented")
override val state: MutableValue<RouterState<C, Any>> = MutableValue(stack.toRouterState())

var stack: List<C>
get() = state.value.backStack.map(Child<C, *>::configuration) + state.value.activeChild.configuration
set(value) {
state.value = value.toRouterState()
}

private fun List<C>.toRouterState(): RouterState<C, Any> =
RouterState<C, Any>(
activeChild = Child.Created(
configuration = last(),
instance = last(),
),
backStack = dropLast(1).map {
Child.Created(
configuration = it,
instance = it,
)
}
)

override fun navigate(transformer: (stack: List<C>) -> List<C>) {
stack = stack.let(transformer)
stack = transformer(stack)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.arkivanov.decompose.router

import com.arkivanov.decompose.Child
import com.arkivanov.decompose.value.Value
import kotlin.math.min

internal fun <T> List<T>.startsWith(other: List<T>): Boolean {
if (other.size > size) {
return false
}

for (i in other.indices) {
if (this[i] != other[i]) {
return false
}
}

return true
}

internal fun <T> List<T>.findFirstDifferentIndex(other: List<T>): Int {
val minSize = min(size, other.size)

if (minSize <= 0) {
return -1;
}

var i = 0;
while ((i < minSize) && (this[i] == other[i])) {
i++
}

return i
}

internal fun <C : Any> RouterState<C, *>.configurations(): List<C> =
backStack.map(Child<C, *>::configuration) + activeChild.configuration

internal fun <T : Any> Value<T>.subscribe(observer: (new: T, old: T) -> Unit) {
var old = value
subscribe { new ->
val tmp = old
old = new
observer(new, tmp)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
package com.arkivanov.decompose.router.webhistory

import com.arkivanov.decompose.ExperimentalDecomposeApi
import com.arkivanov.decompose.router.Router
import com.arkivanov.decompose.router.RouterState
import com.arkivanov.decompose.router.configurations
import com.arkivanov.decompose.router.findFirstDifferentIndex
import com.arkivanov.decompose.router.startsWith
import com.arkivanov.decompose.router.subscribe
import org.w3c.dom.PopStateEvent

@ExperimentalDecomposeApi
class DefaultWebHistoryController(
private val window: Window,
) : WebHistoryController {

constructor() : this(WindowImpl())

override fun <C : Any> attach(
router: Router<C, *>,
getPath: (configuration: C) -> String,
getConfiguration: (path: String) -> C
) {
val impl = Impl(router, window, getPath, getConfiguration)
router.state.subscribe(impl::onStateChanged)
window.onPopState = impl::onPopState
}

private class Impl<in C : Any>(
private val router: Router<C, *>,
private val window: Window,
private val getPath: (C) -> String,
private val getConfiguration: (String) -> C,
) {
private var isStateObserverFirstPass = true
private var isStateObserverEnabled = true

fun onStateChanged(newState: RouterState<C, *>, oldState: RouterState<C, *>) {
if (!isStateObserverEnabled) {
return
}

val newStack = newState.configurations()
val oldStack = oldState.configurations()
val firstDifferentIndex = oldStack.findFirstDifferentIndex(newStack)

when {
// Initialize the history
isStateObserverFirstPass -> {
isStateObserverFirstPass = false
window.history.replaceState(newStack[0])
for (i in 1..newStack.lastIndex) {
window.history.pushState(newStack[i])
}
}

newStack == oldStack -> return

// One or more configurations were popped from the stack
oldStack.startsWith(newStack) -> { // Pop removed pages from the history
window.history.go(delta = newStack.size - oldStack.size)
}

// One or more configurations were pushed to the history
newStack.startsWith(oldStack) -> { // Push new pages to the history
for (i in oldStack.size..newStack.lastIndex) {
window.history.pushState(newStack[i])
}
}

// The active configuration was changed, and new configurations could be pushed
firstDifferentIndex == oldStack.lastIndex -> {
// Replace the current page with a new one
window.history.replaceState(newStack[firstDifferentIndex])

// Push the rest of the pages to the history
for (i in (firstDifferentIndex + 1)..newStack.lastIndex) {
window.history.pushState(newStack[i])
}
}

// Some configurations were popped, and one or more configurations were pushed
firstDifferentIndex > 0 -> {
window.onPopState = {
window.onPopState = ::onPopState

// Push new pages to the history
for (i in firstDifferentIndex..newStack.lastIndex) {
window.history.pushState(newStack[i])
}
}

// Pop removed pages from the history
window.history.go(delta = firstDifferentIndex - oldStack.size)
}

// All configurations were popped, and one or more configurations were pushed
else -> {
window.onPopState = {
window.onPopState = ::onPopState

// Replace the current page with a new one
window.history.replaceState(newStack[firstDifferentIndex])

// Push the rest of the pages to the history
// Corner case: if there is nothing to push, old pages will remain in the history
for (i in (firstDifferentIndex + 1)..newStack.lastIndex) {
window.history.pushState(newStack[i])
}
}

// Pop removed pages from the history, except the first one
window.history.go(delta = -oldStack.lastIndex)
}
}
}

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
}
} 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<String> {
val paths = ArrayList<String>()
val currentConfigurationKey = currentConfiguration.hashCode()
var data: PageData? = nextData

while ((data != null) && (data.configurationKey != currentConfigurationKey)) {
paths += data.path
data = data.prev
}

return paths.asReversed()
}

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)
}

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)
}

private fun History.getData(): PageData? = state?.unsafeCast<PageData>()

private fun PopStateEvent.getData(): PageData? = state?.unsafeCast<PageData>()
}

private data class PageData(
val configurationKey: Int,
val path: String,
var prev: PageData? = null,
var next: PageData? = null,
)

interface Window {
val history: History
var onPopState: ((PopStateEvent) -> Unit)?
}

interface History {
val state: Any?

fun go(delta: Int)
fun pushState(data: Any?, url: String?)
fun replaceState(data: Any?, url: String?)
}

private class WindowImpl : Window {
override val history: History = HistoryImpl()
override var onPopState: ((PopStateEvent) -> Unit)? by kotlinx.browser.window::onpopstate
}

private class HistoryImpl : History {
override val state: Any? by kotlinx.browser.window.history::state

override fun go(delta: Int) {
kotlinx.browser.window.history.go(delta = delta)
}

override fun pushState(data: Any?, url: String?) {
kotlinx.browser.window.history.pushState(data = data, title = "", url = url)
}

override fun replaceState(data: Any?, url: String?) {
kotlinx.browser.window.history.replaceState(data = data, title = "", url = url)
}
}
}
Loading

0 comments on commit 677e297

Please sign in to comment.