From ea50af7c1e0213d5c95216dec3ddf6cf2a185b93 Mon Sep 17 00:00:00 2001 From: Konstantin Tskhovrebov Date: Wed, 1 Oct 2025 12:32:17 +0200 Subject: [PATCH 1/9] Refactor BackHandler implementation to use NavigationBackHandler and refine progress handling logic --- .../compose/ui/backhandler/BackHandler.jb.kt | 96 +++++++++++++++---- 1 file changed, 76 insertions(+), 20 deletions(-) diff --git a/compose/ui/ui-backhandler/src/jbMain/kotlin/androidx/compose/ui/backhandler/BackHandler.jb.kt b/compose/ui/ui-backhandler/src/jbMain/kotlin/androidx/compose/ui/backhandler/BackHandler.jb.kt index 2fe09a7fc4f7e..0effb2caabd81 100644 --- a/compose/ui/ui-backhandler/src/jbMain/kotlin/androidx/compose/ui/backhandler/BackHandler.jb.kt +++ b/compose/ui/ui-backhandler/src/jbMain/kotlin/androidx/compose/ui/backhandler/BackHandler.jb.kt @@ -19,10 +19,26 @@ package androidx.compose.ui.backhandler import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.navigationevent.NavigationEvent +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.NavigationEventTransitionState import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.launch @Deprecated("Use NavigationEventHandler instead") @ExperimentalComposeUiApi @@ -31,33 +47,73 @@ actual fun PredictiveBackHandler( enabled: Boolean, onBack: suspend (progress: Flow) -> Unit ) { - LocalNavigationEventDispatcherOwner.current ?: return - /* - TODO: https://youtrack.jetbrains.com/issue/CMP-8937 - NavigationEventHandler(enabled) { progress -> - val compatProgress = progress.map { navEvent -> - val swipeEdge = when (navEvent.swipeEdge) { - NavigationEventSwipeEdge.Left -> BackEventCompat.EDGE_LEFT - NavigationEventSwipeEdge.Right -> BackEventCompat.EDGE_RIGHT - else -> 0 + val owner = LocalNavigationEventDispatcherOwner.current ?: return + val dispatcher = owner.navigationEventDispatcher + val coroutineScope = rememberCoroutineScope() + + var progressChannel: Channel? by remember(onBack) { + mutableStateOf(null) + } + + fun getActiveProgressChannel(): Channel { + val currentProgressChannel = progressChannel + if (currentProgressChannel == null) { + val progress = Channel() + progressChannel = progress + coroutineScope.launch { + onBack(progress.consumeAsFlow()) } - BackEventCompat(navEvent.touchX, navEvent.touchY, navEvent.progress, swipeEdge) + return progress + } else { + return currentProgressChannel + } + } + + LaunchedEffect(enabled) { + if (enabled) { + dispatcher.transitionState + .filterIsInstance() + .collect { + val navEvent = it.latestEvent + val swipeEdge = when (navEvent.swipeEdge) { + NavigationEvent.EDGE_RIGHT -> BackEventCompat.EDGE_RIGHT + else -> BackEventCompat.EDGE_LEFT + } + val event = BackEventCompat( + navEvent.touchX, navEvent.touchY, navEvent.progress, swipeEdge + ) + getActiveProgressChannel().send(event) + } + } + } + + NavigationBackHandler( + state = rememberNavigationEventState(NavigationEventInfo.None), + isBackEnabled = enabled, + onBackCancelled = { + getActiveProgressChannel().close(CancellationException("Cancelled")) + progressChannel = null + }, + onBackCompleted = { + getActiveProgressChannel().close() + progressChannel = null + } + ) + DisposableEffect(Unit) { + onDispose { + progressChannel?.close(CancellationException("Disposed")) + progressChannel = null } - onBack(compatProgress) } - */ } @Deprecated("Use NavigationEventHandler instead") @ExperimentalComposeUiApi @Composable actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) { - PredictiveBackHandler(enabled) { progress -> - try { - progress.collect { /*ignore*/ } - onBack() - } catch (e: CancellationException) { - //ignore - } - } + NavigationBackHandler( + state = rememberNavigationEventState(NavigationEventInfo.None), + isBackEnabled = enabled, + onBackCompleted = onBack + ) } From 42ae29a90163f5345bff59c65224f8d020a6f65b Mon Sep 17 00:00:00 2001 From: Konstantin Tskhovrebov Date: Wed, 1 Oct 2025 12:42:06 +0200 Subject: [PATCH 2/9] Refactor UIKitNavigationEventInput to align with updated NavigationEvent APIs. --- .../UIKitNavigationEventInput.kt | 48 +++++-------------- 1 file changed, 12 insertions(+), 36 deletions(-) diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/navigationevent/UIKitNavigationEventInput.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/navigationevent/UIKitNavigationEventInput.kt index dc27e06605d68..40969a0e48549 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/navigationevent/UIKitNavigationEventInput.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/navigationevent/UIKitNavigationEventInput.kt @@ -30,7 +30,6 @@ import androidx.compose.ui.unit.asDpRect import androidx.compose.ui.unit.toOffset import androidx.compose.ui.unit.width import androidx.navigationevent.NavigationEvent -import androidx.navigationevent.NavigationEventDispatcher import androidx.navigationevent.NavigationEventInput import kotlin.math.abs import kotlinx.cinterop.BetaInteropApi @@ -57,13 +56,6 @@ internal class UIKitNavigationEventInput( private val density: Density, private val getTopLeftOffsetInWindow: () -> IntOffset ) : NavigationEventInput() { - val isBackGestureActive: Boolean - get() = false - fun onDidMoveToWindow(window: UIWindow?, composeRootView: UIView) {} - fun onKeyEvent(event: KeyEvent): Boolean = false -/* -TODO: https://youtrack.jetbrains.com/issue/CMP-8937 - companion object { private const val BACK_GESTURE_SCREEN_SIZE = 0.3 private const val BACK_GESTURE_VELOCITY = 100 @@ -85,24 +77,9 @@ TODO: https://youtrack.jetbrains.com/issue/CMP-8937 edges = UIRectEdgeRight } - override fun onAdded(dispatcher: NavigationEventDispatcher) { - super.onAdded(dispatcher) - updateRecognizersEnabledState(dispatcher.hasEnabledCallbacks()) - } - - override fun onHasEnabledCallbacksChanged(hasEnabledCallbacks: Boolean) { - super.onHasEnabledCallbacksChanged(hasEnabledCallbacks) - updateRecognizersEnabledState(hasEnabledCallbacks) - } - - override fun onRemoved() { - super.onRemoved() - updateRecognizersEnabledState(false) - } - - private fun updateRecognizersEnabledState(isEnabled: Boolean) { - leftEdgePanGestureRecognizer.enabled = isEnabled - rightEdgePanGestureRecognizer.enabled = isEnabled + override fun onHasEnabledHandlersChanged(hasEnabledHandlers: Boolean) { + leftEdgePanGestureRecognizer.enabled = hasEnabledHandlers + rightEdgePanGestureRecognizer.enabled = hasEnabledHandlers } private val activeGestureStates = listOf( @@ -140,7 +117,7 @@ TODO: https://youtrack.jetbrains.com/issue/CMP-8937 fun onKeyEvent(event: KeyEvent): Boolean { if (event.type == KeyEventType.KeyDown && event.key == Key.Escape) { - dispatchOnCompleted() + dispatchOnBackCompleted() return true } else { return false @@ -157,7 +134,7 @@ TODO: https://youtrack.jetbrains.com/issue/CMP-8937 val touch = recognizer.locationOfTouch(0u, view).asDpOffset() val eventOffset = touch.toOffset(density) - getTopLeftOffsetInWindow().toOffset() - dispatchOnStarted( + dispatchOnBackStarted( NavigationEvent( touchX = eventOffset.x, touchY = eventOffset.y, @@ -183,7 +160,7 @@ TODO: https://youtrack.jetbrains.com/issue/CMP-8937 } else { (bounds.width - touch.x) / bounds.width } - dispatchOnProgressed( + dispatchOnBackProgressed( NavigationEvent( touchX = eventOffset.x, touchY = eventOffset.y, @@ -210,13 +187,13 @@ TODO: https://youtrack.jetbrains.com/issue/CMP-8937 if (edge == UIRectEdgeLeft) this@velocity.x else -this@velocity.x when { //if movement is fast in the right direction - velX > BACK_GESTURE_VELOCITY -> dispatchOnCompleted() + velX > BACK_GESTURE_VELOCITY -> dispatchOnBackCompleted() //if movement is backward - velX < -10 -> dispatchOnCancelled() + velX < -10 -> dispatchOnBackCancelled() //if there is no movement, or the movement is slow, //but the touch is already more than BACK_GESTURE_SCREEN_SIZE - abs(x) >= size.width * BACK_GESTURE_SCREEN_SIZE -> dispatchOnCompleted() - else -> dispatchOnCancelled() + abs(x) >= size.width * BACK_GESTURE_SCREEN_SIZE -> dispatchOnBackCompleted() + else -> dispatchOnBackCancelled() } } } @@ -224,16 +201,15 @@ TODO: https://youtrack.jetbrains.com/issue/CMP-8937 } UIGestureRecognizerStateCancelled -> { - dispatchOnCancelled() + dispatchOnBackCancelled() } UIGestureRecognizerStateFailed -> { - dispatchOnCompleted() + dispatchOnBackCompleted() } } } } -*/ } /** From 4ff87d9e485faac94d409f80daa9e22a5a54d767 Mon Sep 17 00:00:00 2001 From: Konstantin Tskhovrebov Date: Wed, 1 Oct 2025 12:43:37 +0200 Subject: [PATCH 3/9] Integrate NavigationEventDispatcher into web ComposeWindow. --- .../kotlin/androidx/compose/ui/window/ComposeWindow.w3c.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/window/ComposeWindow.w3c.kt b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/window/ComposeWindow.w3c.kt index 67c138cdce648..969a901dd7101 100644 --- a/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/window/ComposeWindow.w3c.kt +++ b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/window/ComposeWindow.w3c.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.input.pointer.composeButton import androidx.compose.ui.input.pointer.composeButtons import androidx.compose.ui.platform.DefaultInputModeManager import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalInternalNavigationEventDispatcherOwner import androidx.compose.ui.platform.LocalInternalViewModelStoreOwner import androidx.compose.ui.platform.PlatformContext import androidx.compose.ui.platform.PlatformDragAndDropManager @@ -73,6 +74,8 @@ import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.navigationevent.NavigationEventDispatcher +import androidx.navigationevent.NavigationEventDispatcherOwner import kotlin.coroutines.coroutineContext import kotlin.math.absoluteValue import kotlinx.browser.document @@ -189,7 +192,7 @@ internal class ComposeWindow( private val configuration: ComposeViewportConfiguration, content: @Composable () -> Unit, private val state: ComposeWindowState -) : LifecycleOwner, ViewModelStoreOwner { +) : LifecycleOwner, ViewModelStoreOwner, NavigationEventDispatcherOwner { private var isDisposed = false private val density: Density = Density( @@ -322,6 +325,7 @@ internal class ComposeWindow( override val lifecycle = LifecycleRegistry(this) override val viewModelStore = ViewModelStore() + override val navigationEventDispatcher = NavigationEventDispatcher() private fun addTypedEvent( type: String, @@ -426,6 +430,7 @@ internal class ComposeWindow( LocalSystemTheme provides systemThemeObserver.currentSystemTheme.value, LocalLifecycleOwner provides this, LocalInternalViewModelStoreOwner provides this, + LocalInternalNavigationEventDispatcherOwner provides this, LocalInteropContainer provides interopContainer, LocalActiveClipEventsTarget provides { (platformContext.textInputService as WebTextInputService).getBackingInput() ?: canvas From 001bd30853cee1c421b3d065fe914fe808eb5fb0 Mon Sep 17 00:00:00 2001 From: Konstantin Tskhovrebov Date: Wed, 1 Oct 2025 15:48:11 +0200 Subject: [PATCH 4/9] Implement default transition specifications for non-Android platforms. --- .../navigation3/ui/NavDisplay.desktop.kt} | 29 +++++-- .../navigation3/ui/NavDisplay.macos.kt | 51 +++++++++++ .../ui/NotImplemented.nonAndroid.kt | 26 ------ .../navigation3/ui/NavDisplay.uikit.kt | 84 +++++++++++++++++++ .../androidx/navigation3/ui/NavDisplay.web.kt | 51 +++++++++++ 5 files changed, 210 insertions(+), 31 deletions(-) rename navigation3/navigation3-ui/src/{nonAndroidMain/kotlin/androidx/navigation3/ui/NavDisplay.nonAndroid.kt => desktopMain/kotlin/androidx/navigation3/ui/NavDisplay.desktop.kt} (54%) create mode 100644 navigation3/navigation3-ui/src/macosMain/kotlin/androidx/navigation3/ui/NavDisplay.macos.kt delete mode 100644 navigation3/navigation3-ui/src/nonAndroidMain/kotlin/androidx/navigation3/ui/NotImplemented.nonAndroid.kt create mode 100644 navigation3/navigation3-ui/src/uikitMain/kotlin/androidx/navigation3/ui/NavDisplay.uikit.kt create mode 100644 navigation3/navigation3-ui/src/webMain/kotlin/androidx/navigation3/ui/NavDisplay.web.kt diff --git a/navigation3/navigation3-ui/src/nonAndroidMain/kotlin/androidx/navigation3/ui/NavDisplay.nonAndroid.kt b/navigation3/navigation3-ui/src/desktopMain/kotlin/androidx/navigation3/ui/NavDisplay.desktop.kt similarity index 54% rename from navigation3/navigation3-ui/src/nonAndroidMain/kotlin/androidx/navigation3/ui/NavDisplay.nonAndroid.kt rename to navigation3/navigation3-ui/src/desktopMain/kotlin/androidx/navigation3/ui/NavDisplay.desktop.kt index 3919117bfd1f3..e0fbb65e9a124 100644 --- a/navigation3/navigation3-ui/src/nonAndroidMain/kotlin/androidx/navigation3/ui/NavDisplay.nonAndroid.kt +++ b/navigation3/navigation3-ui/src/desktopMain/kotlin/androidx/navigation3/ui/NavDisplay.desktop.kt @@ -18,15 +18,34 @@ package androidx.navigation3.ui import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.navigation3.scene.Scene -import androidx.navigationevent.NavigationEvent +import androidx.navigationevent.NavigationEvent.SwipeEdge + +private const val DEFAULT_TRANSITION_DURATION_MILLISECOND = 700 public actual fun defaultTransitionSpec(): - AnimatedContentTransitionScope>.() -> ContentTransform = implementedInJetBrainsFork() + AnimatedContentTransitionScope>.() -> ContentTransform = { + ContentTransform( + fadeIn(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)), + fadeOut(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)), + ) +} public actual fun defaultPopTransitionSpec(): - AnimatedContentTransitionScope>.() -> ContentTransform = implementedInJetBrainsFork() + AnimatedContentTransitionScope>.() -> ContentTransform = { + ContentTransform( + fadeIn(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)), + fadeOut(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)), + ) +} public actual fun defaultPredictivePopTransitionSpec(): - AnimatedContentTransitionScope>.(@NavigationEvent.SwipeEdge Int) -> ContentTransform = - implementedInJetBrainsFork() + AnimatedContentTransitionScope>.(@SwipeEdge Int) -> ContentTransform = { + ContentTransform( + fadeIn(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)), + fadeOut(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)), + ) +} diff --git a/navigation3/navigation3-ui/src/macosMain/kotlin/androidx/navigation3/ui/NavDisplay.macos.kt b/navigation3/navigation3-ui/src/macosMain/kotlin/androidx/navigation3/ui/NavDisplay.macos.kt new file mode 100644 index 0000000000000..e0fbb65e9a124 --- /dev/null +++ b/navigation3/navigation3-ui/src/macosMain/kotlin/androidx/navigation3/ui/NavDisplay.macos.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.navigation3.ui + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.navigation3.scene.Scene +import androidx.navigationevent.NavigationEvent.SwipeEdge + +private const val DEFAULT_TRANSITION_DURATION_MILLISECOND = 700 + +public actual fun defaultTransitionSpec(): + AnimatedContentTransitionScope>.() -> ContentTransform = { + ContentTransform( + fadeIn(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)), + fadeOut(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)), + ) +} + +public actual fun defaultPopTransitionSpec(): + AnimatedContentTransitionScope>.() -> ContentTransform = { + ContentTransform( + fadeIn(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)), + fadeOut(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)), + ) +} + +public actual fun defaultPredictivePopTransitionSpec(): + AnimatedContentTransitionScope>.(@SwipeEdge Int) -> ContentTransform = { + ContentTransform( + fadeIn(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)), + fadeOut(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)), + ) +} diff --git a/navigation3/navigation3-ui/src/nonAndroidMain/kotlin/androidx/navigation3/ui/NotImplemented.nonAndroid.kt b/navigation3/navigation3-ui/src/nonAndroidMain/kotlin/androidx/navigation3/ui/NotImplemented.nonAndroid.kt deleted file mode 100644 index 02e4ff321d9bc..0000000000000 --- a/navigation3/navigation3-ui/src/nonAndroidMain/kotlin/androidx/navigation3/ui/NotImplemented.nonAndroid.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.navigation3.ui - -@Suppress("NOTHING_TO_INLINE") -internal inline fun implementedInJetBrainsFork(): Nothing = - throw NotImplementedError( - """ - Implemented only in JetBrains fork. - """ - .trimIndent() - ) diff --git a/navigation3/navigation3-ui/src/uikitMain/kotlin/androidx/navigation3/ui/NavDisplay.uikit.kt b/navigation3/navigation3-ui/src/uikitMain/kotlin/androidx/navigation3/ui/NavDisplay.uikit.kt new file mode 100644 index 0000000000000..6f979ae2657e2 --- /dev/null +++ b/navigation3/navigation3-ui/src/uikitMain/kotlin/androidx/navigation3/ui/NavDisplay.uikit.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.navigation3.ui + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleOut +import androidx.navigation3.scene.Scene +import androidx.navigationevent.NavigationEvent.Companion.EDGE_LEFT +import androidx.navigationevent.NavigationEvent.SwipeEdge + +private const val DEFAULT_TRANSITION_DURATION_MILLISECOND = 500 +private val IosTransitionEasing = CubicBezierEasing(0.2833f, 0.99f, 0.31833f, 0.99f) + +public actual fun defaultTransitionSpec(): + AnimatedContentTransitionScope>.() -> ContentTransform = { + ContentTransform( + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Left, + initialOffset = { it / 4 }, + animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND, easing = IosTransitionEasing), + ), + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Left, + targetOffset = { it / 4 }, + animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND, easing = IosTransitionEasing), + ), + ) +} + +public actual fun defaultPopTransitionSpec(): + AnimatedContentTransitionScope>.() -> ContentTransform = { + ContentTransform( + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Right, + initialOffset = { it / 4 }, + animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND, easing = IosTransitionEasing), + ), + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND, easing = IosTransitionEasing), + ), + ) +} + +public actual fun defaultPredictivePopTransitionSpec(): + AnimatedContentTransitionScope>.(@SwipeEdge Int) -> ContentTransform = { edge -> + val towards = if (edge == EDGE_LEFT) { + AnimatedContentTransitionScope.SlideDirection.Right + } else { + AnimatedContentTransitionScope.SlideDirection.Left + } + ContentTransform( + slideIntoContainer( + towards = towards, + initialOffset = { it / 4 }, + animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND, easing = LinearEasing), + ), + slideOutOfContainer( + towards = towards, + animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND, easing = LinearEasing), + ), + ) +} diff --git a/navigation3/navigation3-ui/src/webMain/kotlin/androidx/navigation3/ui/NavDisplay.web.kt b/navigation3/navigation3-ui/src/webMain/kotlin/androidx/navigation3/ui/NavDisplay.web.kt new file mode 100644 index 0000000000000..e0fbb65e9a124 --- /dev/null +++ b/navigation3/navigation3-ui/src/webMain/kotlin/androidx/navigation3/ui/NavDisplay.web.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.navigation3.ui + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.navigation3.scene.Scene +import androidx.navigationevent.NavigationEvent.SwipeEdge + +private const val DEFAULT_TRANSITION_DURATION_MILLISECOND = 700 + +public actual fun defaultTransitionSpec(): + AnimatedContentTransitionScope>.() -> ContentTransform = { + ContentTransform( + fadeIn(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)), + fadeOut(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)), + ) +} + +public actual fun defaultPopTransitionSpec(): + AnimatedContentTransitionScope>.() -> ContentTransform = { + ContentTransform( + fadeIn(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)), + fadeOut(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)), + ) +} + +public actual fun defaultPredictivePopTransitionSpec(): + AnimatedContentTransitionScope>.(@SwipeEdge Int) -> ContentTransform = { + ContentTransform( + fadeIn(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)), + fadeOut(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)), + ) +} From 52b14779d84cebf5608a86e7854b613695d3ad64 Mon Sep 17 00:00:00 2001 From: Konstantin Tskhovrebov Date: Wed, 1 Oct 2025 17:18:56 +0200 Subject: [PATCH 5/9] Integrate NavigationEventDispatcher into ui-test and update SkikoTestOwner implementation. --- compose/ui/ui-test/build.gradle | 1 + .../androidx/compose/ui/test/ComposeUiTest.skiko.kt | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/compose/ui/ui-test/build.gradle b/compose/ui/ui-test/build.gradle index 621d6654abc2f..906e3e942ee83 100644 --- a/compose/ui/ui-test/build.gradle +++ b/compose/ui/ui-test/build.gradle @@ -151,6 +151,7 @@ if (AndroidXComposePlugin.isMultiplatformEnabled(project)) { dependencies { implementation(libs.atomicFu) implementation(project(":lifecycle:lifecycle-runtime-compose")) + implementation(project(":navigationevent:navigationevent-compose")) } } diff --git a/compose/ui/ui-test/src/skikoMain/kotlin/androidx/compose/ui/test/ComposeUiTest.skiko.kt b/compose/ui/ui-test/src/skikoMain/kotlin/androidx/compose/ui/test/ComposeUiTest.skiko.kt index f482dd5bd2c2a..6511cc08e2a14 100644 --- a/compose/ui/ui-test/src/skikoMain/kotlin/androidx/compose/ui/test/ComposeUiTest.skiko.kt +++ b/compose/ui/ui-test/src/skikoMain/kotlin/androidx/compose/ui/test/ComposeUiTest.skiko.kt @@ -44,6 +44,9 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.navigationevent.NavigationEventDispatcher +import androidx.navigationevent.NavigationEventDispatcherOwner +import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.cancellation.CancellationException @@ -456,7 +459,10 @@ open class SkikoComposeUiTest @InternalTestApi constructor( } @OptIn(InternalComposeUiApi::class) - internal inner class SkikoTestOwner : TestOwner, LifecycleOwner { + internal inner class SkikoTestOwner : + TestOwner, + LifecycleOwner, + NavigationEventDispatcherOwner { override val mainClock get() = mainClockImpl @@ -474,6 +480,7 @@ open class SkikoComposeUiTest @InternalTestApi constructor( } override val lifecycle = LifecycleRegistry.createUnsafe(this) + override val navigationEventDispatcher = NavigationEventDispatcher() fun captureToImage(semanticsNode: SemanticsNode): ImageBitmap = this@SkikoComposeUiTest.captureToImage(semanticsNode) @@ -535,6 +542,7 @@ open class SkikoComposeUiTest @InternalTestApi constructor( private fun ProvideCommonCompositionLocals(content: @Composable () -> Unit) { CompositionLocalProvider( LocalLifecycleOwner provides testOwner, + LocalNavigationEventDispatcherOwner provides testOwner, content = content, ) } From 8180899b00c4a69458c8d59b8cf990c0c24b5cff Mon Sep 17 00:00:00 2001 From: Konstantin Tskhovrebov Date: Wed, 1 Oct 2025 17:21:00 +0200 Subject: [PATCH 6/9] Dump desktop navigation3-ui API. --- navigation3/navigation3-ui/api/desktop/navigation3-ui.api | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/navigation3/navigation3-ui/api/desktop/navigation3-ui.api b/navigation3/navigation3-ui/api/desktop/navigation3-ui.api index 7b86f5915c41e..f09a3eb540df8 100644 --- a/navigation3/navigation3-ui/api/desktop/navigation3-ui.api +++ b/navigation3/navigation3-ui/api/desktop/navigation3-ui.api @@ -83,7 +83,7 @@ public final class androidx/navigation3/ui/NavDisplayKt { public static final fun NavDisplay (Ljava/util/List;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;Lkotlin/jvm/functions/Function1;Ljava/util/List;Landroidx/navigation3/scene/SceneStrategy;Landroidx/compose/animation/SizeTransform;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;III)V } -public final class androidx/navigation3/ui/NavDisplay_nonAndroidKt { +public final class androidx/navigation3/ui/NavDisplay_desktopKt { public static final fun defaultPopTransitionSpec ()Lkotlin/jvm/functions/Function1; public static final fun defaultPredictivePopTransitionSpec ()Lkotlin/jvm/functions/Function2; public static final fun defaultTransitionSpec ()Lkotlin/jvm/functions/Function1; From 75288bb8d52b387a93c4e7780fbb425fa0abb1f4 Mon Sep 17 00:00:00 2001 From: Konstantin Tskhovrebov Date: Wed, 1 Oct 2025 19:21:45 +0200 Subject: [PATCH 7/9] Annotate NavDisplay with `@JvmName`. --- navigation3/navigation3-ui/api/desktop/navigation3-ui.api | 3 --- .../commonMain/kotlin/androidx/navigation3/ui/NavDisplay.kt | 5 +++++ .../kotlin/androidx/navigation3/ui/NavDisplay.desktop.kt | 3 +++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/navigation3/navigation3-ui/api/desktop/navigation3-ui.api b/navigation3/navigation3-ui/api/desktop/navigation3-ui.api index f09a3eb540df8..18d48176a93d7 100644 --- a/navigation3/navigation3-ui/api/desktop/navigation3-ui.api +++ b/navigation3/navigation3-ui/api/desktop/navigation3-ui.api @@ -81,9 +81,6 @@ public final class androidx/navigation3/ui/NavDisplayKt { public static final fun NavDisplay (Landroidx/navigation3/scene/SceneState;Landroidx/navigationevent/compose/NavigationEventState;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;Landroidx/compose/animation/SizeTransform;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V public static final fun NavDisplay (Ljava/util/List;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;Landroidx/navigation3/scene/SceneStrategy;Landroidx/compose/animation/SizeTransform;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V public static final fun NavDisplay (Ljava/util/List;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;Lkotlin/jvm/functions/Function1;Ljava/util/List;Landroidx/navigation3/scene/SceneStrategy;Landroidx/compose/animation/SizeTransform;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;III)V -} - -public final class androidx/navigation3/ui/NavDisplay_desktopKt { public static final fun defaultPopTransitionSpec ()Lkotlin/jvm/functions/Function1; public static final fun defaultPredictivePopTransitionSpec ()Lkotlin/jvm/functions/Function2; public static final fun defaultTransitionSpec ()Lkotlin/jvm/functions/Function1; diff --git a/navigation3/navigation3-ui/src/commonMain/kotlin/androidx/navigation3/ui/NavDisplay.kt b/navigation3/navigation3-ui/src/commonMain/kotlin/androidx/navigation3/ui/NavDisplay.kt index 592e85092eb23..3b46361cefe1c 100644 --- a/navigation3/navigation3-ui/src/commonMain/kotlin/androidx/navigation3/ui/NavDisplay.kt +++ b/navigation3/navigation3-ui/src/commonMain/kotlin/androidx/navigation3/ui/NavDisplay.kt @@ -14,6 +14,9 @@ * limitations under the License. */ +@file:JvmName("NavDisplayKt") +@file:JvmMultifileClass + package androidx.navigation3.ui import androidx.collection.mutableObjectFloatMapOf @@ -59,6 +62,8 @@ import androidx.navigationevent.NavigationEventTransitionState.InProgress import androidx.navigationevent.compose.NavigationBackHandler import androidx.navigationevent.compose.NavigationEventState import androidx.navigationevent.compose.rememberNavigationEventState +import kotlin.jvm.JvmMultifileClass +import kotlin.jvm.JvmName import kotlin.reflect.KClass import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch diff --git a/navigation3/navigation3-ui/src/desktopMain/kotlin/androidx/navigation3/ui/NavDisplay.desktop.kt b/navigation3/navigation3-ui/src/desktopMain/kotlin/androidx/navigation3/ui/NavDisplay.desktop.kt index e0fbb65e9a124..bf9ed1842583b 100644 --- a/navigation3/navigation3-ui/src/desktopMain/kotlin/androidx/navigation3/ui/NavDisplay.desktop.kt +++ b/navigation3/navigation3-ui/src/desktopMain/kotlin/androidx/navigation3/ui/NavDisplay.desktop.kt @@ -14,6 +14,9 @@ * limitations under the License. */ +@file:JvmName("NavDisplayKt") +@file:JvmMultifileClass + package androidx.navigation3.ui import androidx.compose.animation.AnimatedContentTransitionScope From 53865523be6767d6d4e98e71448e39a431e113d0 Mon Sep 17 00:00:00 2001 From: Konstantin Tskhovrebov Date: Thu, 2 Oct 2025 10:40:35 +0200 Subject: [PATCH 8/9] Refactor BackHandler to utilize NavigationEventState and simplify progress handling --- .../compose/ui/backhandler/BackHandler.jb.kt | 33 ++++++++----------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/compose/ui/ui-backhandler/src/jbMain/kotlin/androidx/compose/ui/backhandler/BackHandler.jb.kt b/compose/ui/ui-backhandler/src/jbMain/kotlin/androidx/compose/ui/backhandler/BackHandler.jb.kt index 0effb2caabd81..6418b175c9d88 100644 --- a/compose/ui/ui-backhandler/src/jbMain/kotlin/androidx/compose/ui/backhandler/BackHandler.jb.kt +++ b/compose/ui/ui-backhandler/src/jbMain/kotlin/androidx/compose/ui/backhandler/BackHandler.jb.kt @@ -37,7 +37,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.consumeAsFlow -import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch @Deprecated("Use NavigationEventHandler instead") @@ -47,9 +46,8 @@ actual fun PredictiveBackHandler( enabled: Boolean, onBack: suspend (progress: Flow) -> Unit ) { - val owner = LocalNavigationEventDispatcherOwner.current ?: return - val dispatcher = owner.navigationEventDispatcher val coroutineScope = rememberCoroutineScope() + val navEventState = rememberNavigationEventState(NavigationEventInfo.None) var progressChannel: Channel? by remember(onBack) { mutableStateOf(null) @@ -69,26 +67,23 @@ actual fun PredictiveBackHandler( } } - LaunchedEffect(enabled) { - if (enabled) { - dispatcher.transitionState - .filterIsInstance() - .collect { - val navEvent = it.latestEvent - val swipeEdge = when (navEvent.swipeEdge) { - NavigationEvent.EDGE_RIGHT -> BackEventCompat.EDGE_RIGHT - else -> BackEventCompat.EDGE_LEFT - } - val event = BackEventCompat( - navEvent.touchX, navEvent.touchY, navEvent.progress, swipeEdge - ) - getActiveProgressChannel().send(event) - } + val transitionState = navEventState.transitionState + if (transitionState is NavigationEventTransitionState.InProgress) { + LaunchedEffect(transitionState) { + val navEvent = transitionState.latestEvent + val swipeEdge = when (navEvent.swipeEdge) { + NavigationEvent.EDGE_RIGHT -> BackEventCompat.EDGE_RIGHT + else -> BackEventCompat.EDGE_LEFT + } + val event = BackEventCompat( + navEvent.touchX, navEvent.touchY, navEvent.progress, swipeEdge + ) + getActiveProgressChannel().send(event) } } NavigationBackHandler( - state = rememberNavigationEventState(NavigationEventInfo.None), + state = navEventState, isBackEnabled = enabled, onBackCancelled = { getActiveProgressChannel().close(CancellationException("Cancelled")) From 1a9eeaff5c6eafc3f12ef5702b7a51561c2f433d Mon Sep 17 00:00:00 2001 From: Konstantin Tskhovrebov Date: Thu, 2 Oct 2025 12:03:52 +0200 Subject: [PATCH 9/9] Improve iOS default transactions --- .../uikitMain/kotlin/androidx/navigation3/ui/NavDisplay.uikit.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/navigation3/navigation3-ui/src/uikitMain/kotlin/androidx/navigation3/ui/NavDisplay.uikit.kt b/navigation3/navigation3-ui/src/uikitMain/kotlin/androidx/navigation3/ui/NavDisplay.uikit.kt index 6f979ae2657e2..f91a66b61c9a7 100644 --- a/navigation3/navigation3-ui/src/uikitMain/kotlin/androidx/navigation3/ui/NavDisplay.uikit.kt +++ b/navigation3/navigation3-ui/src/uikitMain/kotlin/androidx/navigation3/ui/NavDisplay.uikit.kt @@ -37,7 +37,6 @@ public actual fun defaultTransitionSpec(): ContentTransform( slideIntoContainer( towards = AnimatedContentTransitionScope.SlideDirection.Left, - initialOffset = { it / 4 }, animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND, easing = IosTransitionEasing), ), slideOutOfContainer(