diff --git a/build.gradle.kts b/build.gradle.kts index 68856a84..cfa0793f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -34,7 +34,7 @@ allprojects { } extra.apply { - set("precomposeVersion", "1.5.11") + set("precomposeVersion", "1.6.0-rc05") set("jvmTarget", "11") diff --git a/gradle.properties b/gradle.properties index 0ae109cf..aed0f14f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,6 +18,7 @@ org.gradle.jvmargs=-Xmx4g org.jetbrains.compose.experimental.jscanvas.enabled=true org.jetbrains.compose.experimental.macos.enabled=true org.jetbrains.compose.experimental.uikit.enabled=true +org.jetbrains.compose.experimental.wasm.enabled=true kotlin.mpp.androidSourceSetLayoutVersion=2 android.defaults.buildfeatures.buildconfig=true android.nonTransitiveRClass=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 601e7ab5..e6edf714 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,24 +1,24 @@ [versions] # also check project root build.gradle.kts for versions -androidx-animation = "1.5.3" -androidx-foundation = "1.5.3" +androidx-animation = "1.6.1" +androidx-foundation = "1.6.1" androidx-appcompat = "1.6.1" androidx-coreKtx = "1.12.0" -androidxActivityVer = "1.8.0" -androidGradlePlugin = "8.1.1" +androidxActivityVer = "1.8.2" +androidGradlePlugin = "8.2.2" junit = "4.13.2" junitJupiterEngine = "5.10.1" junitJupiterApi = "5.10.1" -kotlin = "1.9.21" -kotlinxCoroutinesCore = "1.7.3" -lifecycleRuntimeKtx = "2.6.2" -material = "1.5.0" +kotlin = "1.9.22" +lifecycleRuntimeKtx = "2.7.0" +material = "1.6.1" +kotlinxCoroutinesCore = "1.8.0" moleculeRuntime = "1.3.2" savedstateKtx = "1.2.1" spotless = "6.25.0" -jetbrainsComposePlugin = "1.5.11" -skiko = "0.7.90" -koin = "3.5.0" -koin-compose = "1.1.2" +jetbrainsComposePlugin = "1.6.0" +skiko = "0.7.93" +koin = "3.6.0-alpha3" +uuid = "0.8.2" [libraries] androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "androidxActivityVer" } @@ -41,11 +41,12 @@ molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref skiko = { module = "org.jetbrains.skiko:skiko", version.ref = "skiko" } skiko-js = { module = "org.jetbrains.skiko:skiko-js-wasm-runtime", version.ref = "skiko" } koin = { module = "io.insert-koin:koin-core", version.ref = "koin" } -koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin-compose" } +koin-compose = { module = "io.insert-koin:koin-compose", version = "1.2.0-alpha3" } +uuid = { module = "com.benasher44:uuid", version.ref = "uuid" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "jetbrainsComposePlugin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } -spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } \ No newline at end of file diff --git a/precompose-koin/build.gradle.kts b/precompose-koin/build.gradle.kts index 07032ad2..8cfdaca8 100644 --- a/precompose-koin/build.gradle.kts +++ b/precompose-koin/build.gradle.kts @@ -12,7 +12,6 @@ group = "moe.tlaster" version = rootProject.extra.get("precomposeVersion") as String kotlin { - applyDefaultHierarchyTemplate { common { group("jvmAndroid") { @@ -41,6 +40,10 @@ kotlin { js(IR) { browser() } + wasmJs { + browser() + binaries.executable() + } sourceSets { val commonMain by getting { dependencies { @@ -106,6 +109,10 @@ kotlin { } } +// adding it here to make sure skiko is unpacked and available in web tests +compose.experimental { + web.application {} +} android { compileSdk = rootProject.extra.get("android-compile") as Int buildToolsVersion = rootProject.extra.get("android-build-tools") as String diff --git a/precompose-molecule/src/iosMain/kotlin/moe/tlaster/precompose/molecule/ProvidePlatformDispatcher.kt b/precompose-molecule/src/iosMain/kotlin/moe/tlaster/precompose/molecule/ProvidePlatformDispatcher.kt index 31a18009..5e86b4cd 100644 --- a/precompose-molecule/src/iosMain/kotlin/moe/tlaster/precompose/molecule/ProvidePlatformDispatcher.kt +++ b/precompose-molecule/src/iosMain/kotlin/moe/tlaster/precompose/molecule/ProvidePlatformDispatcher.kt @@ -1,6 +1,7 @@ package moe.tlaster.precompose.molecule +import app.cash.molecule.DisplayLinkClock import kotlinx.coroutines.Dispatchers import kotlin.coroutines.CoroutineContext -internal actual fun providePlatformDispatcher(): CoroutineContext = Dispatchers.Main +internal actual fun providePlatformDispatcher(): CoroutineContext = DisplayLinkClock + Dispatchers.Main diff --git a/precompose-viewmodel/build.gradle.kts b/precompose-viewmodel/build.gradle.kts index 9947af50..e150c4fb 100644 --- a/precompose-viewmodel/build.gradle.kts +++ b/precompose-viewmodel/build.gradle.kts @@ -40,6 +40,10 @@ kotlin { js(IR) { browser() } + wasmJs { + browser() + binaries.executable() + } sourceSets { val commonMain by getting { dependencies { @@ -99,9 +103,17 @@ kotlin { implementation(compose.foundation) } } + val wasmJsMain by getting { + dependencies { + implementation(compose.foundation) + } + } } } - +// adding it here to make sure skiko is unpacked and available in web tests +compose.experimental { + web.application {} +} android { compileSdk = rootProject.extra.get("android-compile") as Int buildToolsVersion = rootProject.extra.get("android-build-tools") as String diff --git a/precompose/build.gradle.kts b/precompose/build.gradle.kts index e5f706db..b2861c99 100644 --- a/precompose/build.gradle.kts +++ b/precompose/build.gradle.kts @@ -1,3 +1,6 @@ +import org.jetbrains.compose.ExperimentalComposeLibrary +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree import java.util.Properties plugins { @@ -20,6 +23,16 @@ kotlin { iosSimulatorArm64() androidTarget { publishLibraryVariants("release", "debug") + + @OptIn(ExperimentalKotlinGradlePluginApi::class) + instrumentedTestVariant { + sourceSetTree.set(KotlinSourceSetTree.test) + + dependencies { + implementation("androidx.compose.ui:ui-test-junit4-android:1.5.4") + debugImplementation("androidx.compose.ui:ui-test-manifest:1.5.4") + } + } } jvm { compilations.all { @@ -32,6 +45,10 @@ kotlin { js(IR) { browser() } + wasmJs { + browser() + binaries.executable() + } sourceSets { val commonMain by getting { dependencies { @@ -39,6 +56,7 @@ kotlin { compileOnly(compose.animation) compileOnly(compose.material) api(libs.kotlinx.coroutines.core) + implementation(libs.uuid) } } val commonTest by getting { @@ -48,6 +66,8 @@ kotlin { implementation(kotlin("test-common")) implementation(kotlin("test-annotations-common")) implementation(libs.kotlinx.coroutines.test) + @OptIn(ExperimentalComposeLibrary::class) + implementation(compose.uiTest) // @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) // implementation(compose.uiTestJUnit4) } @@ -91,6 +111,7 @@ kotlin { implementation(kotlin("test-junit5")) implementation(libs.junit.jupiter.api) runtimeOnly(libs.junit.jupiter.engine) + implementation(compose.desktop.currentOs) } } val jsMain by getting { @@ -112,15 +133,26 @@ kotlin { implementation(compose.material) } } + val wasmJsMain by getting { + dependencies { + implementation(compose.foundation) + implementation(compose.animation) + implementation(compose.material) + } + } } } - +// adding it here to make sure skiko is unpacked and available in web tests +compose.experimental { + web.application {} +} android { compileSdk = rootProject.extra.get("android-compile") as Int buildToolsVersion = rootProject.extra.get("android-build-tools") as String namespace = "moe.tlaster.precompose" defaultConfig { minSdk = rootProject.extra.get("androidMinSdk") as Int + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } compileOptions { sourceCompatibility = JavaVersion.toVersion(rootProject.extra.get("jvmTarget") as String) diff --git a/precompose/src/androidMain/kotlin/moe/tlaster/precompose/lifecycle/PreComposeViewModel.kt b/precompose/src/androidMain/kotlin/moe/tlaster/precompose/lifecycle/PreComposeViewModel.kt index 264ef0d4..34d0f424 100644 --- a/precompose/src/androidMain/kotlin/moe/tlaster/precompose/lifecycle/PreComposeViewModel.kt +++ b/precompose/src/androidMain/kotlin/moe/tlaster/precompose/lifecycle/PreComposeViewModel.kt @@ -1,5 +1,6 @@ package moe.tlaster.precompose.lifecycle +import androidx.activity.BackEventCompat import androidx.activity.OnBackPressedCallback import moe.tlaster.precompose.stateholder.StateHolder import moe.tlaster.precompose.ui.BackDispatcher @@ -26,6 +27,18 @@ internal class PreComposeViewModel : override fun handleOnBackPressed() { backDispatcher.onBackPress() } + + override fun handleOnBackStarted(backEvent: BackEventCompat) { + backDispatcher.onBackStarted() + } + + override fun handleOnBackProgressed(backEvent: BackEventCompat) { + backDispatcher.onBackProgressed(backEvent.progress) + } + + override fun handleOnBackCancelled() { + backDispatcher.onBackCancelled() + } } override fun onCleared() { diff --git a/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/BackHandler.kt b/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/BackHandler.kt index f9977ad4..ce31d18c 100644 --- a/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/BackHandler.kt +++ b/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/BackHandler.kt @@ -5,8 +5,19 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.BUFFERED +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.launch import moe.tlaster.precompose.lifecycle.currentLocalLifecycleOwner +import moe.tlaster.precompose.ui.BackDispatcher +import moe.tlaster.precompose.ui.BackHandler import moe.tlaster.precompose.ui.DefaultBackHandler import moe.tlaster.precompose.ui.LocalBackDispatcherOwner @@ -36,3 +47,127 @@ fun BackHandler(enabled: Boolean = true, onBack: () -> Unit) { } } } + +/** + * An effect for handling predictive system back gestures. + * + * Calling this in your composable adds the given lambda to the [BackDispatcher] of the + * [LocalBackDispatcherOwner]. The lambda passes in a Flow where each + * [Float] reflects the progress of current gesture back. The lambda content should + * follow this structure: + * + * ``` + * PredictiveBackHandler { progress: Flow -> + * // code for gesture back started + * try { + * progress.collect { progress -> + * // code for progress + * } + * // code for completion + * } catch (e: CancellationException) { + * // code for cancellation + * } + * } + * ``` + * + * If this is called by nested composables, if enabled, the inner most composable will consume + * the call to system back and invoke its lambda. The call will continue to propagate up until it + * finds an enabled BackHandler. + * + * @param enabled if this BackHandler should be enabled, true by default + * @param onBack the action invoked by back gesture + */ +@Composable +fun PredictiveBackHandler( + enabled: Boolean = true, + onBack: suspend (progress: Flow) -> Unit, +) { + // ensure we don't re-register callbacks when onBack changes + val currentOnBack by rememberUpdatedState(onBack) + val onBackScope = rememberCoroutineScope() + + val backCallback = remember { + object : BackHandler { + override var isEnabled: Boolean = enabled + var onBackInstance: OnBackInstance? = null + + override fun handleBackStarted() { + // in case the previous onBackInstance was started by a normal back gesture + // we want to make sure it's still cancelled before we start a predictive + // back gesture + onBackInstance?.cancel() + onBackInstance = OnBackInstance(onBackScope, true, currentOnBack) + } + + override fun handleBackProgressed(progress: Float) { + onBackInstance?.send(progress) + } + + override fun handleBackPress() { + // handleOnBackPressed could be called by regular back to restart + // a new back instance. If this is the case (where current back instance + // was NOT started by handleOnBackStarted) then we need to reset the previous + // regular back. + onBackInstance?.apply { + if (!isPredictiveBack) { + cancel() + onBackInstance = null + } + } + if (onBackInstance == null) { + onBackInstance = OnBackInstance(onBackScope, false, currentOnBack) + } + + // finally, we close the channel to ensure no more events can be sent + // but let the job complete normally + onBackInstance?.close() + } + + override fun handleBackCancelled() { + // cancel will purge the channel of any sent events that are yet to be received + onBackInstance?.cancel() + } + } + } + + val backDispatcher = checkNotNull(LocalBackDispatcherOwner.current) { + "No OnBackPressedDispatcherOwner was provided via LocalOnBackPressedDispatcherOwner" + }.backDispatcher + + SideEffect { + if (backCallback.isEnabled != enabled) { + backDispatcher.onBackStackChanged() + } + backCallback.isEnabled = enabled + } + val lifecycleOwner = currentLocalLifecycleOwner + + DisposableEffect(lifecycleOwner, backDispatcher) { + backDispatcher.register(backCallback) + + onDispose { + backDispatcher.unregister(backCallback) + } + } +} + +private class OnBackInstance( + scope: CoroutineScope, + val isPredictiveBack: Boolean, + onBack: suspend (progress: Flow) -> Unit, +) { + val channel = Channel(capacity = BUFFERED, onBufferOverflow = BufferOverflow.SUSPEND) + val job = scope.launch { + onBack(channel.consumeAsFlow()) + } + + fun send(backEvent: Float) = channel.trySend(backEvent) + + // idempotent if invoked more than once + fun close() = channel.close() + + fun cancel() { + channel.cancel(CancellationException("onBack cancelled")) + job.cancel() + } +} diff --git a/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/BackStackEntry.kt b/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/BackStackEntry.kt index dda7f22e..9fd55e1c 100644 --- a/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/BackStackEntry.kt +++ b/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/BackStackEntry.kt @@ -11,7 +11,7 @@ import moe.tlaster.precompose.stateholder.SavedStateHolder import moe.tlaster.precompose.stateholder.StateHolder class BackStackEntry internal constructor( - val id: Long, + internal val stateId: String, internal var routeInternal: Route, val path: String, val pathMap: Map, @@ -23,7 +23,6 @@ class BackStackEntry internal constructor( get() = routeInternal internal var uiClosable: UiClosable? = null private var _destroyAfterTransition = false - internal val stateId = "$id-${route.route}" val stateHolder: StateHolder = parentStateHolder.getOrPut(stateId) { StateHolder() } diff --git a/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/BackStackManager.kt b/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/BackStackManager.kt index 3472c5a3..ab3affeb 100644 --- a/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/BackStackManager.kt +++ b/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/BackStackManager.kt @@ -1,9 +1,7 @@ package moe.tlaster.precompose.navigation import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue +import com.benasher44.uuid.uuid4 import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow @@ -75,8 +73,6 @@ internal class BackStackManager : LifecycleObserver { val currentFloatingBackStackEntry: Flow get() = backStacks.asSharedFlow().map { it.lastOrNull { it.route.isFloatingRoute() } } - var canNavigate by mutableStateOf(true) - fun init( stateHolder: StateHolder, savedStateHolder: SavedStateHolder, @@ -111,9 +107,6 @@ internal class BackStackManager : LifecycleObserver { } fun push(path: String, options: NavOptions? = null) { - if (!canNavigate) { - return - } val currentBackStacks = backStacks.value val query = path.substringAfter('?', "") val routePath = path.substringBefore('?') @@ -127,11 +120,11 @@ internal class BackStackManager : LifecycleObserver { ) { currentBackStacks.firstOrNull { it.hasRoute(matchResult.route.route, path, options.includePath) } ?.let { entry -> - backStacks.value = backStacks.value.filter { it.id != entry.id } + entry + backStacks.value = backStacks.value.filter { it.stateId != entry.stateId } + entry } } else { backStacks.value += BackStackEntry( - id = (backStacks.value.lastOrNull()?.id ?: 0L) + 1, + stateId = uuid4().toString(), routeInternal = matchResult.route, pathMap = matchResult.pathMap, queryString = query.takeIf { it.isNotEmpty() }?.let { @@ -174,9 +167,6 @@ internal class BackStackManager : LifecycleObserver { } fun pop(result: Any? = null) { - if (!canNavigate) { - return - } val currentBackStacks = backStacks.value if (currentBackStacks.size > 1) { val last = currentBackStacks.last() @@ -189,9 +179,6 @@ internal class BackStackManager : LifecycleObserver { fun popWithOptions( popUpTo: PopUpTo, ) { - if (!canNavigate) { - return - } val currentBackStacks = backStacks.value if (currentBackStacks.size <= 1) { return diff --git a/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/NavHost.kt b/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/NavHost.kt index cd874359..d920782d 100644 --- a/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/NavHost.kt +++ b/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/NavHost.kt @@ -3,59 +3,51 @@ package moe.tlaster.precompose.navigation import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ContentTransform -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.togetherWith +import androidx.compose.animation.core.ExperimentalTransitionApi +import androidx.compose.animation.core.SeekableTransitionState +import androidx.compose.animation.core.rememberTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.gestures.snapTo import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.offset -import androidx.compose.material.DismissDirection -import androidx.compose.material.DismissState -import androidx.compose.material.DismissValue -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.ResistanceConfig -import androidx.compose.material.SwipeableDefaults -import androidx.compose.material.ThresholdConfig -import androidx.compose.material.rememberDismissState -import androidx.compose.material.swipeable +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.key +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.SaveableStateHolder import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex +import kotlinx.coroutines.CancellationException import moe.tlaster.precompose.lifecycle.LocalLifecycleOwner import moe.tlaster.precompose.lifecycle.currentLocalLifecycleOwner import moe.tlaster.precompose.navigation.route.ComposeRoute +import moe.tlaster.precompose.navigation.route.FloatingRoute import moe.tlaster.precompose.navigation.route.GroupRoute import moe.tlaster.precompose.navigation.transition.NavTransition import moe.tlaster.precompose.stateholder.LocalSavedStateHolder import moe.tlaster.precompose.stateholder.LocalStateHolder import moe.tlaster.precompose.stateholder.currentLocalSavedStateHolder import moe.tlaster.precompose.stateholder.currentLocalStateHolder -import kotlin.math.absoluteValue -import kotlin.math.roundToInt /** * Provides in place in the Compose hierarchy for self-contained navigation to occur. @@ -72,7 +64,10 @@ import kotlin.math.roundToInt * @param swipeProperties properties of swipe back navigation * @param builder the builder used to construct the graph */ -@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterialApi::class) +@OptIn( + ExperimentalTransitionApi::class, + ExperimentalFoundationApi::class, +) @Composable fun NavHost( navigator: Navigator, @@ -102,23 +97,6 @@ fun NavHost( ) } - val transitionSpec: AnimatedContentTransitionScope.() -> ContentTransform = { - val actualTransaction = run { - if (navigator.stackManager.contains(initialState)) targetState else initialState - }.navTransition ?: navTransition - if (!navigator.stackManager.contains(initialState)) { - actualTransaction.resumeTransition.togetherWith(actualTransaction.destroyTransition) - .apply { - targetContentZIndex = actualTransaction.enterTargetContentZIndex - } - } else { - actualTransaction.createTransition.togetherWith(actualTransaction.pauseTransition) - .apply { - targetContentZIndex = actualTransaction.exitTargetContentZIndex - } - } - } - val canGoBack by navigator.stackManager.canGoBack.collectAsState(false) val currentEntry by navigator.stackManager.currentBackStackEntry.collectAsState(null) @@ -134,162 +112,150 @@ fun NavHost( } } - Box(modifier) { + BoxWithConstraints(modifier) { val currentSceneEntry by navigator.stackManager .currentSceneBackStackEntry.collectAsState(null) val prevSceneEntry by navigator.stackManager .prevSceneBackStackEntry.collectAsState(null) - - BackHandler(canGoBack) { - navigator.goBack() + var progress by remember { mutableFloatStateOf(0f) } + var inPredictiveBack by remember { mutableStateOf(false) } + PredictiveBackHandler(canGoBack) { backEvent -> + inPredictiveBack = true + progress = 0f + try { + backEvent.collect { + progress = it + } + if (progress != 1f) { + // play the animation to the end + progress = 1f + } + } catch (e: CancellationException) { + inPredictiveBack = false + } } - currentSceneEntry?.let { sceneEntry -> val actualSwipeProperties = sceneEntry.swipeProperties ?: swipeProperties - if (actualSwipeProperties == null) { - AnimatedContent(sceneEntry, transitionSpec = transitionSpec) { entry -> - SideEffect { - navigator.stackManager.canNavigate = !transition.isRunning + val state = if (actualSwipeProperties != null) { + val density = LocalDensity.current + val width = constraints.maxWidth.toFloat() + remember { + AnchoredDraggableState( + initialValue = DragAnchors.Start, + anchors = DraggableAnchors { + DragAnchors.Start at 0f + DragAnchors.End at width + }, + positionalThreshold = actualSwipeProperties.positionalThreshold, + velocityThreshold = { actualSwipeProperties.velocityThreshold.invoke(density) }, + animationSpec = tween(), + ) + }.also { state -> + LaunchedEffect( + state.currentValue, + state.isAnimationRunning, + ) { + if (state.currentValue == DragAnchors.End && !state.isAnimationRunning) { + // play the animation to the end + progress = 1f + state.snapTo(DragAnchors.Start) + } + } + LaunchedEffect(state.progress) { + if (state.progress != 1f) { + inPredictiveBack = state.progress > 0f + progress = state.progress + } else if (state.currentValue != DragAnchors.End && inPredictiveBack) { + // reset the state to the initial value + progress = -1f + } } - NavHostContent(composeStateHolder, entry) } } else { - var prevWasSwiped by remember { - mutableStateOf(false) - } - - LaunchedEffect(currentSceneEntry) { - prevWasSwiped = false + null + } + val showPrev by remember(inPredictiveBack, prevSceneEntry, currentEntry) { + derivedStateOf { + inPredictiveBack && + progress != 0f && + prevSceneEntry != null && + currentEntry?.route !is FloatingRoute } - - val dismissState = key(sceneEntry) { - rememberDismissState() + } + val transition = if (showPrev) { + val transitionState by remember(sceneEntry) { + mutableStateOf(SeekableTransitionState(sceneEntry, prevSceneEntry!!)) } - - LaunchedEffect( - dismissState.isDismissed(DismissDirection.StartToEnd), - dismissState.isAnimationRunning, - ) { - navigator.stackManager.canNavigate = !dismissState.isAnimationRunning - if (dismissState.isDismissed(DismissDirection.StartToEnd) && !dismissState.isAnimationRunning) { - prevWasSwiped = true + LaunchedEffect(progress) { + if (progress == 1f) { + // play the animation to the end + transitionState.animateToTargetState() + inPredictiveBack = false navigator.goBack() + progress = 0f + } else if (progress >= 0) { + transitionState.snapToFraction(progress) + } else if (progress == -1f) { + // reset the state to the initial value + transitionState.animateToCurrentState() + inPredictiveBack = false + progress = 0f } } - - val showPrev by remember(dismissState) { - derivedStateOf { - dismissState.offset.value > 0f - } - } - - val visibleItems = remember(sceneEntry, prevSceneEntry, showPrev) { - if (showPrev) { - listOfNotNull(sceneEntry, prevSceneEntry) - } else { - listOfNotNull(sceneEntry) - } - } - - // display visible items using SwipeItem - visibleItems.forEachIndexed { index, backStackEntry -> - val isPrev = remember(index, visibleItems.size) { - index == 1 && visibleItems.size > 1 - } - AnimatedContent( - backStackEntry, - transitionSpec = { - if (prevWasSwiped) { - EnterTransition.None togetherWith ExitTransition.None - } else { - transitionSpec() - } - }, - modifier = Modifier.zIndex( - if (isPrev) { - 0f - } else { - 1f - }, - ), - ) { - SideEffect { - navigator.stackManager.canNavigate = !transition.isRunning - } - SwipeItem( - dismissState = dismissState, - swipeProperties = actualSwipeProperties, - isPrev = isPrev, - isLast = !canGoBack, - enabled = !transition.isRunning, - ) { - NavHostContent(composeStateHolder, it) - } - } + rememberTransition(transitionState, label = "entry") + } else { + updateTransition(sceneEntry, label = "entry") + } + val transitionSpec: AnimatedContentTransitionScope.() -> ContentTransform = { + val actualTransaction = run { + if (navigator.stackManager.contains(initialState) && !showPrev) targetState else initialState + }.navTransition ?: navTransition + if (!navigator.stackManager.contains(initialState) || showPrev) { + ContentTransform( + targetContentEnter = actualTransaction.resumeTransition, + initialContentExit = actualTransaction.destroyTransition, + targetContentZIndex = actualTransaction.enterTargetContentZIndex, + // sizeTransform will cause the content to be resized + // when the transition is running with swipe back + // I have no idea why + // And it cost me weeks to figure it out :( + sizeTransform = null, + ) + } else { + ContentTransform( + targetContentEnter = actualTransaction.createTransition, + initialContentExit = actualTransaction.pauseTransition, + targetContentZIndex = actualTransaction.exitTargetContentZIndex, + sizeTransform = null, + ) } } + transition.AnimatedContent( + transitionSpec = transitionSpec, + contentKey = { it.stateId }, + ) { entry -> + NavHostContent(composeStateHolder, entry) + } + if (state != null) { + DragSlider( + state = state, + enabled = prevSceneEntry != null, + ) + } } val currentFloatingEntry by navigator.stackManager .currentFloatingBackStackEntry.collectAsState(null) currentFloatingEntry?.let { - AnimatedContent(it, transitionSpec = transitionSpec) { entry -> - SideEffect { - navigator.stackManager.canNavigate = !transition.isRunning - } + AnimatedContent( + it, + contentKey = { it.stateId }, + ) { entry -> NavHostContent(composeStateHolder, entry) } } } } -@OptIn(ExperimentalMaterialApi::class) -@Composable -private fun SwipeItem( - dismissState: DismissState, - swipeProperties: SwipeProperties, - isPrev: Boolean, - isLast: Boolean, - enabled: Boolean, - modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { - if (enabled) { - CustomSwipeToDismiss( - state = if (isPrev) rememberDismissState() else dismissState, - spaceToSwipe = swipeProperties.spaceToSwipe, - enabled = !isLast, - dismissThreshold = swipeProperties.swipeThreshold, - modifier = modifier, - ) { - Box( - modifier = Modifier - .takeIf { isPrev } - ?.graphicsLayer { - translationX = - swipeProperties.slideInHorizontally(size.width.toInt()) - .toFloat() - - swipeProperties.slideInHorizontally( - dismissState.offset.value.absoluteValue.toInt(), - ) - }?.drawWithContent { - drawContent() - drawRect( - swipeProperties.shadowColor, - alpha = (1f - dismissState.progress.fraction) * - swipeProperties.shadowColor.alpha, - ) - }?.pointerInput(0) { - // prev entry should not be interactive until fully appeared - } ?: Modifier, - ) { - content.invoke() - } - } - } else { - content.invoke() - } -} - @Composable private fun NavHostContent( stateHolder: SaveableStateHolder, @@ -330,49 +296,29 @@ private fun BackStackEntry.ComposeContent() { }?.content?.invoke(this) } +@OptIn(ExperimentalFoundationApi::class) @Composable -@ExperimentalMaterialApi -private fun CustomSwipeToDismiss( - state: DismissState, +private fun DragSlider( + state: AnchoredDraggableState, enabled: Boolean = true, spaceToSwipe: Dp = 10.dp, modifier: Modifier = Modifier, - dismissThreshold: ThresholdConfig, - dismissContent: @Composable () -> Unit, -) = BoxWithConstraints(modifier) { - val width = constraints.maxWidth.toFloat() +) { val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl - - val anchors = mutableMapOf( - 0f to DismissValue.Default, - width to DismissValue.DismissedToEnd, - ) - - val shift = with(LocalDensity.current) { - remember(this, width, spaceToSwipe) { - (-width + spaceToSwipe.toPx().coerceIn(0f, width)).roundToInt() - } - } Box( - modifier = Modifier - .offset { IntOffset(x = shift, 0) } - .swipeable( + modifier = modifier + .fillMaxHeight() + .width(spaceToSwipe) + .anchoredDraggable( state = state, - anchors = anchors, - thresholds = { _, _ -> dismissThreshold }, orientation = Orientation.Horizontal, - enabled = enabled && state.currentValue == DismissValue.Default, + enabled = enabled, reverseDirection = isRtl, - resistance = ResistanceConfig( - basis = width, - factorAtMin = SwipeableDefaults.StiffResistanceFactor, - factorAtMax = SwipeableDefaults.StandardResistanceFactor, - ), - ) - .offset { IntOffset(x = -shift, 0) } - .graphicsLayer { translationX = state.offset.value }, + ), + ) +} - ) { - dismissContent() - } +private enum class DragAnchors { + Start, + End, } diff --git a/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/Navigator.kt b/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/Navigator.kt index a1ca4f44..b6595d7a 100644 --- a/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/Navigator.kt +++ b/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/Navigator.kt @@ -1,7 +1,7 @@ package moe.tlaster.precompose.navigation import androidx.compose.runtime.Composable -import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.map import moe.tlaster.precompose.lifecycle.LifecycleOwner import moe.tlaster.precompose.stateholder.SavedStateHolder import moe.tlaster.precompose.stateholder.StateHolder @@ -151,8 +151,7 @@ class Navigator { val previousEntry = stackManager.prevBackStackEntry /** - * Check if navigator can navigate, it will be false when performing navigation animation. - * @return Returns true if navigator can perform navigation, false otherwise. + * Number of routes in the back stack */ - val canNavigate = snapshotFlow { stackManager.canNavigate } + val backStackCount = stackManager.backStacks.map { it.size } } diff --git a/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/RouteBuilder.kt b/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/RouteBuilder.kt index f43839dc..85bd9152 100644 --- a/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/RouteBuilder.kt +++ b/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/RouteBuilder.kt @@ -1,9 +1,6 @@ package moe.tlaster.precompose.navigation import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import moe.tlaster.precompose.navigation.route.FloatingRoute import moe.tlaster.precompose.navigation.route.GroupRoute import moe.tlaster.precompose.navigation.route.Route @@ -15,24 +12,6 @@ class RouteBuilder( ) { private val route = arrayListOf() - private fun sceneInternal( - route: String, - deepLinks: List, - navTransition: NavTransition?, - swipeProperties: SwipeProperties?, - content: @Composable (State) -> Unit, - ) { - addRoute( - SceneRoute( - route = route, - navTransition = navTransition, - deepLinks = deepLinks, - swipeProperties = swipeProperties, - content = { content(remember { mutableStateOf(it) }) }, - ), - ) - } - /** * Add the scene [Composable] to the [RouteBuilder] * @param route route for the destination @@ -47,12 +26,14 @@ class RouteBuilder( swipeProperties: SwipeProperties? = null, content: @Composable (BackStackEntry) -> Unit, ) { - sceneInternal( - route = route, - navTransition = navTransition, - deepLinks = deepLinks, - swipeProperties = swipeProperties, - content = { content(it.value) }, + addRoute( + SceneRoute( + route = route, + navTransition = navTransition, + deepLinks = deepLinks, + swipeProperties = swipeProperties, + content = content, + ), ) } @@ -95,18 +76,6 @@ class RouteBuilder( ) } - private fun floatingInternal( - route: String, - content: @Composable (State) -> Unit, - ) { - addRoute( - FloatingRoute( - route = route, - content = { content(remember { mutableStateOf(it) }) }, - ), - ) - } - /** * Add the floating [Composable] to the [RouteBuilder], which will show over the scene * @param route route for the destination @@ -116,9 +85,11 @@ class RouteBuilder( route: String, content: @Composable (BackStackEntry) -> Unit, ) { - floatingInternal( - route = route, - content = { content(it.value) }, + addRoute( + FloatingRoute( + route = route, + content = content, + ), ) } diff --git a/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/SwipeProperties.kt b/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/SwipeProperties.kt index ed62499b..8e6e56c1 100644 --- a/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/SwipeProperties.kt +++ b/precompose/src/commonMain/kotlin/moe/tlaster/precompose/navigation/SwipeProperties.kt @@ -2,31 +2,17 @@ package moe.tlaster.precompose.navigation -import androidx.compose.animation.core.AnimationSpec -import androidx.compose.animation.core.tween import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.FixedThreshold -import androidx.compose.material.ThresholdConfig -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import moe.tlaster.precompose.navigation.transition.NavTransition /** - * @param slideInHorizontally a lambda that takes the full width of the - * content in pixels and returns the initial offset for the slide-in prev entry, - * by default it returns -fullWidth/4. - * For the best impression, lambda should return the save value as [NavTransition.pauseTransition] * @param spaceToSwipe width of the swipe space from the left side of screen. * Can be set to [Int.MAX_VALUE].dp to enable full-scene swipe - * @param swipeThreshold amount of offset to perform back navigation - * @param shadowColor color of the shadow. Alpha channel is additionally multiplied - * by swipe progress. Use [Color.Transparent] to disable shadow * */ class SwipeProperties( - val slideInHorizontally: (fullWidth: Int) -> Int = { -it / 4 }, val spaceToSwipe: Dp = 10.dp, - val swipeThreshold: ThresholdConfig = FixedThreshold(56.dp), - val shadowColor: Color = Color.Black.copy(alpha = .25f), - val swipeAnimSpec: AnimationSpec = tween(), + val positionalThreshold: (totalDistance: Float) -> Float = { distance: Float -> distance * 0.5f }, + val velocityThreshold: Density.() -> Float = { 56.dp.toPx() }, ) diff --git a/precompose/src/commonMain/kotlin/moe/tlaster/precompose/ui/BackPressAdapter.kt b/precompose/src/commonMain/kotlin/moe/tlaster/precompose/ui/BackPressAdapter.kt index fcf9a750..46d41e60 100644 --- a/precompose/src/commonMain/kotlin/moe/tlaster/precompose/ui/BackPressAdapter.kt +++ b/precompose/src/commonMain/kotlin/moe/tlaster/precompose/ui/BackPressAdapter.kt @@ -14,11 +14,36 @@ interface BackDispatcherOwner { class BackDispatcher { // internal for testing internal val handlers = arrayListOf() + private var inProgressHandler: BackHandler? = null fun onBackPress() { - handlers.lastOrNull { + val handler = currentHandler() + inProgressHandler = null + handler?.handleBackPress() + } + + fun onBackProgressed(progress: Float) { + currentHandler()?.handleBackProgressed(progress) + } + + fun onBackCancelled() { + val handler = currentHandler() + inProgressHandler = null + handler?.handleBackCancelled() + } + + fun onBackStarted() { + val handler = handlers.lastOrNull { it.isEnabled - }?.handleBackPress() + } + inProgressHandler = handler + handler?.handleBackStarted() + } + + private fun currentHandler(): BackHandler? { + return inProgressHandler ?: handlers.lastOrNull { + it.isEnabled + } } private val canHandleBackPressFlow = MutableStateFlow(0) @@ -44,6 +69,9 @@ class BackDispatcher { interface BackHandler { val isEnabled: Boolean fun handleBackPress() + fun handleBackProgressed(progress: Float) + fun handleBackCancelled() + fun handleBackStarted() } internal class DefaultBackHandler( @@ -53,4 +81,13 @@ internal class DefaultBackHandler( override fun handleBackPress() { onBackPress() } + + override fun handleBackCancelled() { + } + + override fun handleBackStarted() { + } + + override fun handleBackProgressed(progress: Float) { + } } diff --git a/precompose/src/commonTest/kotlin/moe/tlaster/precompose/navigation/BackDispatcherTest.kt b/precompose/src/commonTest/kotlin/moe/tlaster/precompose/navigation/BackDispatcherTest.kt index bbadb426..867a77e0 100644 --- a/precompose/src/commonTest/kotlin/moe/tlaster/precompose/navigation/BackDispatcherTest.kt +++ b/precompose/src/commonTest/kotlin/moe/tlaster/precompose/navigation/BackDispatcherTest.kt @@ -15,10 +15,16 @@ class BackDispatcherTest { val handler1 = object : BackHandler { override val isEnabled = true override fun handleBackPress() {} + override fun handleBackProgressed(progress: Float) {} + override fun handleBackCancelled() {} + override fun handleBackStarted() {} } val handler2 = object : BackHandler { override val isEnabled = true override fun handleBackPress() {} + override fun handleBackProgressed(progress: Float) {} + override fun handleBackCancelled() {} + override fun handleBackStarted() {} } dispatcher.register(handler1) dispatcher.register(handler2) @@ -34,10 +40,16 @@ class BackDispatcherTest { val handler1 = object : BackHandler { override val isEnabled = true override fun handleBackPress() {} + override fun handleBackProgressed(progress: Float) {} + override fun handleBackCancelled() {} + override fun handleBackStarted() {} } val handler2 = object : BackHandler { override val isEnabled = true override fun handleBackPress() {} + override fun handleBackProgressed(progress: Float) {} + override fun handleBackCancelled() {} + override fun handleBackStarted() {} } dispatcher.register(handler1) dispatcher.register(handler2) diff --git a/precompose/src/commonTest/kotlin/moe/tlaster/precompose/navigation/BackStackEntryTest.kt b/precompose/src/commonTest/kotlin/moe/tlaster/precompose/navigation/BackStackEntryTest.kt index d70fda54..f4d51b6d 100644 --- a/precompose/src/commonTest/kotlin/moe/tlaster/precompose/navigation/BackStackEntryTest.kt +++ b/precompose/src/commonTest/kotlin/moe/tlaster/precompose/navigation/BackStackEntryTest.kt @@ -1,5 +1,6 @@ package moe.tlaster.precompose.navigation +import com.benasher44.uuid.uuid4 import moe.tlaster.precompose.lifecycle.Lifecycle import moe.tlaster.precompose.stateholder.StateHolder import kotlin.test.Test @@ -12,7 +13,7 @@ class BackStackEntryTest { fun testActive() { val parentStateHolder = StateHolder() val entry = BackStackEntry( - 0L, + uuid4().toString(), TestRoute("foo/bar", "foo/bar"), "foo/bar", emptyMap(), @@ -29,7 +30,7 @@ class BackStackEntryTest { fun testInActive() { val parentStateHolder = StateHolder() val entry = BackStackEntry( - 0L, + uuid4().toString(), TestRoute("foo/bar", "foo/bar"), "foo/bar", emptyMap(), @@ -47,7 +48,7 @@ class BackStackEntryTest { fun testDestroy() { val parentStateHolder = StateHolder() val entry = BackStackEntry( - 0L, + uuid4().toString(), TestRoute("foo/bar", "foo/bar"), "foo/bar", emptyMap(), @@ -67,7 +68,7 @@ class BackStackEntryTest { fun testDestroyAfterTransition() { val parentStateHolder = StateHolder() val entry = BackStackEntry( - 0L, + uuid4().toString(), TestRoute("foo/bar", "foo/bar"), "foo/bar", emptyMap(), diff --git a/precompose/src/commonTest/kotlin/moe/tlaster/precompose/navigation/BackStackManagerTest.kt b/precompose/src/commonTest/kotlin/moe/tlaster/precompose/navigation/BackStackManagerTest.kt index 82526c06..39eb12c0 100644 --- a/precompose/src/commonTest/kotlin/moe/tlaster/precompose/navigation/BackStackManagerTest.kt +++ b/precompose/src/commonTest/kotlin/moe/tlaster/precompose/navigation/BackStackManagerTest.kt @@ -7,6 +7,7 @@ import moe.tlaster.precompose.stateholder.StateHolder import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs +import kotlin.test.assertNotEquals class BackStackManagerTest { @Test @@ -488,10 +489,10 @@ class BackStackManagerTest { manager.backStacks.value.map { it.path }, ) - assertEquals( - listOf("1-screen1", "3-screen1", "4-screen1"), - manager.backStacks.value.map { it.stateId }, - ) + // assertEquals( + // listOf("1-screen1", "3-screen1", "4-screen1"), + // manager.backStacks.value.map { it.stateId }, + // ) } @Test @@ -697,4 +698,63 @@ class BackStackManagerTest { manager.backStacks.value.map { it.path }, ) } + + @Test + fun testStateId() { + val manager = BackStackManager() + val lifecycleOwner = TestLifecycleOwner() + val saveableStateHolder = TestSavedStateHolder() + manager.init( + stateHolder = StateHolder(), + savedStateHolder = saveableStateHolder, + lifecycleOwner = lifecycleOwner, + ) + manager.setRouteGraph( + routeGraph = RouteGraph( + "screen1", + listOf( + TestRoute("screen1", "screen1"), + TestRoute("screen2", "screen2"), + TestRoute("screen3", "screen3"), + ), + ), + ) + manager.push("screen2") + manager.push("screen3") + val lastEntry = manager.backStacks.value.last() + val stateId = lastEntry.stateId + manager.pop() + lastEntry.destroyDirectly() + manager.push("screen3") + assertNotEquals(stateId, manager.backStacks.value.last().stateId) + } + + @Test + fun testStateIdWithoutDestroy() { + val manager = BackStackManager() + val lifecycleOwner = TestLifecycleOwner() + val saveableStateHolder = TestSavedStateHolder() + manager.init( + stateHolder = StateHolder(), + savedStateHolder = saveableStateHolder, + lifecycleOwner = lifecycleOwner, + ) + manager.setRouteGraph( + routeGraph = RouteGraph( + "screen1", + listOf( + TestRoute("screen1", "screen1"), + TestRoute("screen2", "screen2"), + TestRoute("screen3", "screen3"), + ), + ), + ) + manager.push("screen2") + manager.push("screen3") + val lastEntry = manager.backStacks.value.last() + val stateId = lastEntry.stateId + manager.pop() + manager.push("screen3") + assertNotEquals(stateId, manager.backStacks.value.last().stateId) + } } diff --git a/precompose/src/jvmTest/kotlin/moe/tlaster/precompose/navigation/NavHostTest.kt b/precompose/src/jvmTest/kotlin/moe/tlaster/precompose/navigation/NavHostTest.kt new file mode 100644 index 00000000..f7194c4d --- /dev/null +++ b/precompose/src/jvmTest/kotlin/moe/tlaster/precompose/navigation/NavHostTest.kt @@ -0,0 +1,72 @@ +package moe.tlaster.precompose.navigation + +import androidx.compose.foundation.layout.Column +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import moe.tlaster.precompose.PreComposeApp +import kotlin.test.Test + +class NavHostTest { + + @OptIn(ExperimentalTestApi::class) + @Test + fun navigationTest() = runComposeUiTest { + setContent { + PreComposeApp { + val navigator = rememberNavigator() + Column { + Button(onClick = { + navigator.goBack() + }, modifier = Modifier.testTag("goback")) { + Text("Go Back") + } + Button(onClick = { + navigator.navigate("/1") + }, modifier = Modifier.testTag("to1")) { + Text("1") + } + Button(onClick = { + navigator.navigate("/2") + }, modifier = Modifier.testTag("to2")) { + Text("2") + } + Button(onClick = { + navigator.navigate("/3") + }, modifier = Modifier.testTag("to3")) { + Text("3") + } + NavHost( + navigator = navigator, + initialRoute = "/1", + ) { + scene("/1") { + Text("1", modifier = Modifier.testTag("screen1")) + Text("1", modifier = Modifier.testTag("text")) + } + scene("/2") { + Text("2", modifier = Modifier.testTag("screen2")) + Text("2", modifier = Modifier.testTag("text")) + } + scene("/3") { + Text("3", modifier = Modifier.testTag("screen3")) + Text("3", modifier = Modifier.testTag("text")) + } + } + } + } + } + onNodeWithTag("text").assertTextEquals("1") + onNodeWithTag("to2").performClick() + onNodeWithTag("to3").performClick() + onNodeWithTag("to1").performClick() + onNodeWithTag("to3").performClick() + onNodeWithTag("text").assertTextEquals("3") + } +} diff --git a/precompose/src/macosMain/kotlin/moe/tlaster/precompose/ComposeWindow.kt b/precompose/src/macosMain/kotlin/moe/tlaster/precompose/ComposeWindow.kt index 23458bd4..c75c8f96 100644 --- a/precompose/src/macosMain/kotlin/moe/tlaster/precompose/ComposeWindow.kt +++ b/precompose/src/macosMain/kotlin/moe/tlaster/precompose/ComposeWindow.kt @@ -3,31 +3,18 @@ package moe.tlaster.precompose import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.createSkiaLayer -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.native.ComposeLayer -import androidx.compose.ui.node.LayoutNode -import androidx.compose.ui.platform.AccessibilityController -import androidx.compose.ui.platform.DefaultInputModeManager -import androidx.compose.ui.platform.EmptyFocusManager import androidx.compose.ui.platform.MacosTextInputService -import androidx.compose.ui.platform.Platform -import androidx.compose.ui.platform.TextToolbar -import androidx.compose.ui.platform.TextToolbarStatus -import androidx.compose.ui.platform.ViewConfiguration +import androidx.compose.ui.platform.PlatformContext import androidx.compose.ui.platform.WindowInfoImpl -import androidx.compose.ui.semantics.SemanticsOwner import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.dp import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.ObjCAction import kotlinx.cinterop.useContents +import org.jetbrains.skiko.SkiaLayer +import org.jetbrains.skiko.SkikoInput import platform.AppKit.NSBackingStoreBuffered import platform.AppKit.NSWindow import platform.AppKit.NSWindowDelegateProtocol @@ -62,53 +49,22 @@ internal class ComposeWindow( ) } private val macosTextInputService = MacosTextInputService() - private val platform: Platform = object : Platform { - override val windowInfo = WindowInfoImpl().apply { - // true is a better default if platform doesn't provide WindowInfo. - // otherwise UI will be rendered always in unfocused mode - // (hidden textfield cursor, gray titlebar, etc) - isWindowFocused = true - } - - override var dialogScrimBlendMode by mutableStateOf(BlendMode.SrcOver) - - override val inputModeManager = DefaultInputModeManager() - override val focusManager = EmptyFocusManager - - override fun requestFocusForOwner() = false - - override fun accessibilityController(owner: SemanticsOwner) = object : AccessibilityController { - override fun onSemanticsChange() = Unit - override fun onLayoutChange(layoutNode: LayoutNode) = Unit - override suspend fun syncLoop() = Unit - } + private val _windowInfo = WindowInfoImpl().apply { + isWindowFocused = true + } - override fun setPointerIcon(pointerIcon: PointerIcon) = Unit - override val viewConfiguration = object : ViewConfiguration { - override val longPressTimeoutMillis: Long = 500 - override val doubleTapTimeoutMillis: Long = 300 - override val doubleTapMinTimeMillis: Long = 40 - override val touchSlop: Float get() = with(density) { 18.dp.toPx() } - } - override val textToolbar: TextToolbar = object : TextToolbar { - override fun hide() = Unit - override val status: TextToolbarStatus = TextToolbarStatus.Hidden - override fun showMenu( - rect: Rect, - onCopyRequested: (() -> Unit)?, - onPasteRequested: (() -> Unit)?, - onCutRequested: (() -> Unit)?, - onSelectAllRequested: (() -> Unit)?, - ) = Unit + @OptIn(InternalComposeUiApi::class) + private val platformContext: PlatformContext = + object : PlatformContext by PlatformContext.Empty { + override val windowInfo get() = _windowInfo + override val textInputService get() = macosTextInputService } - override val textInputService = macosTextInputService - } - - val layer = ComposeLayer( - layer = createSkiaLayer(), - platform = platform, - input = macosTextInputService.input, + @OptIn(InternalComposeUiApi::class) + private val layer = ComposeLayer( + layer = SkiaLayer(), + platformContext = platformContext, + input = SkikoInput.Empty, ) val title: String get() = nsWindow.title() diff --git a/precompose/src/wasmJsMain/kotlin/moe/tlaster/precompose/PreComposeApp.kt b/precompose/src/wasmJsMain/kotlin/moe/tlaster/precompose/PreComposeApp.kt new file mode 100644 index 00000000..b49f4479 --- /dev/null +++ b/precompose/src/wasmJsMain/kotlin/moe/tlaster/precompose/PreComposeApp.kt @@ -0,0 +1,50 @@ +package moe.tlaster.precompose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import moe.tlaster.precompose.lifecycle.LifecycleOwner +import moe.tlaster.precompose.lifecycle.LifecycleRegistry +import moe.tlaster.precompose.lifecycle.LocalLifecycleOwner +import moe.tlaster.precompose.stateholder.LocalStateHolder +import moe.tlaster.precompose.stateholder.StateHolder +import moe.tlaster.precompose.ui.BackDispatcher +import moe.tlaster.precompose.ui.BackDispatcherOwner +import moe.tlaster.precompose.ui.LocalBackDispatcherOwner + +@Composable +actual fun PreComposeApp( + content: @Composable () -> Unit, +) { + ProvidePreComposeCompositionLocals { + content.invoke() + } +} + +@Composable +fun ProvidePreComposeCompositionLocals( + holder: PreComposeWindowHolder = remember { + PreComposeWindowHolder() + }, + content: @Composable () -> Unit, +) { + CompositionLocalProvider( + LocalLifecycleOwner provides holder, + LocalStateHolder provides holder.stateHolder, + LocalBackDispatcherOwner provides holder, + ) { + content.invoke() + } +} + +class PreComposeWindowHolder : LifecycleOwner, BackDispatcherOwner { + override val lifecycle by lazy { + LifecycleRegistry() + } + val stateHolder by lazy { + StateHolder() + } + override val backDispatcher by lazy { + BackDispatcher() + } +} diff --git a/precompose/src/wasmJsMain/kotlin/moe/tlaster/precompose/reflect/KClass.wasm.kt b/precompose/src/wasmJsMain/kotlin/moe/tlaster/precompose/reflect/KClass.wasm.kt new file mode 100644 index 00000000..f15fb450 --- /dev/null +++ b/precompose/src/wasmJsMain/kotlin/moe/tlaster/precompose/reflect/KClass.wasm.kt @@ -0,0 +1,7 @@ +package moe.tlaster.precompose.reflect + +import kotlin.reflect.KClass + +actual val KClass.canonicalName: String? + // qualifiedName is unsupported [This reflection API is not supported yet in WASM] + get() = this.simpleName diff --git a/sample/molecule/build.gradle.kts b/sample/molecule/build.gradle.kts index dc75a264..7f220e16 100644 --- a/sample/molecule/build.gradle.kts +++ b/sample/molecule/build.gradle.kts @@ -8,18 +8,12 @@ plugins { } kotlin { - ios("uikit") { - binaries { - executable { - entryPoint = "moe.tlaster.precompose.molecule.sample.main" - freeCompilerArgs += listOf( - "-linker-option", "-framework", "-linker-option", "Metal", - "-linker-option", "-framework", "-linker-option", "CoreText", - "-linker-option", "-framework", "-linker-option", "CoreGraphics", - ) - } - } - } + applyDefaultHierarchyTemplate() + listOf( + iosSimulatorArm64(), + iosArm64(), + iosX64(), + ) androidTarget() macosX64 { binaries { @@ -69,27 +63,6 @@ kotlin { implementation(libs.androidx.activity.compose) } } - val darwinMain by creating { - dependsOn(commonMain) - dependencies { - } - } - val uikitMain by getting { - dependsOn(darwinMain) - dependencies { - } - } - val macosMain by creating { - dependsOn(darwinMain) - dependencies { - } - } - val macosX64Main by getting { - dependsOn(macosMain) - } - val macosArm64Main by getting { - dependsOn(macosMain) - } } targets.withType { diff --git a/sample/todo/common/build.gradle.kts b/sample/todo/common/build.gradle.kts index f1aa876f..64b9ea07 100644 --- a/sample/todo/common/build.gradle.kts +++ b/sample/todo/common/build.gradle.kts @@ -5,10 +5,12 @@ plugins { } kotlin { - targetHierarchy.default() + applyDefaultHierarchyTemplate() macosX64() macosArm64() - ios() + iosArm64() + iosX64() + iosSimulatorArm64() androidTarget() jvm("desktop") js(IR) { diff --git a/sample/todo/common/src/commonMain/kotlin/moe/tlaster/common/scene/NoteDetailScene.kt b/sample/todo/common/src/commonMain/kotlin/moe/tlaster/common/scene/NoteDetailScene.kt index 4ddb06c8..ab4bfd40 100644 --- a/sample/todo/common/src/commonMain/kotlin/moe/tlaster/common/scene/NoteDetailScene.kt +++ b/sample/todo/common/src/commonMain/kotlin/moe/tlaster/common/scene/NoteDetailScene.kt @@ -11,7 +11,7 @@ import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Edit import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -37,7 +37,7 @@ fun NoteDetailScene( }, navigationIcon = { IconButton(onClick = { onBack.invoke() }) { - Icon(Icons.Default.ArrowBack, contentDescription = null) + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) } }, actions = { diff --git a/sample/todo/common/src/commonMain/kotlin/moe/tlaster/common/scene/NoteEditScene.kt b/sample/todo/common/src/commonMain/kotlin/moe/tlaster/common/scene/NoteEditScene.kt index 782c6ad6..1f3072f3 100644 --- a/sample/todo/common/src/commonMain/kotlin/moe/tlaster/common/scene/NoteEditScene.kt +++ b/sample/todo/common/src/commonMain/kotlin/moe/tlaster/common/scene/NoteEditScene.kt @@ -12,7 +12,7 @@ import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Done import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -55,7 +55,7 @@ fun NoteEditScene( }, navigationIcon = { IconButton(onClick = { onBack.invoke() }) { - Icon(Icons.Default.ArrowBack, contentDescription = null) + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) } }, ) diff --git a/settings.gradle.kts b/settings.gradle.kts index 2a8dbb33..0ec70f57 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,6 +16,8 @@ dependencyResolutionManagement { maven("https://maven.mozilla.org/maven2/") maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") maven("https://jitpack.io") + // TODO: delete when we have all libs in mavenCentral + maven("https://maven.pkg.jetbrains.space/kotlin/p/wasm/experimental") } } rootProject.name = "precompose"