Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,33 +47,73 @@ actual fun PredictiveBackHandler(
enabled: Boolean,
onBack: suspend (progress: Flow<BackEventCompat>) -> Unit

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that onBack is still called after cancellation. Not sure if it's our fault. For example:

PredictiveBackHandler(true) { progress ->
    Log.d("gyz", "start")
    try {
        progress.collect { Log.d("gyz", it.toString()) }
        Log.d("gyz", "finish")
    } catch(e: Exception) {
        Log.d("gyz", "cancelled")
    }
}

And after it prints "cancelled", it then prints another "start" and back event.

) {
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<BackEventCompat>? by remember(onBack) {
mutableStateOf(null)
}

fun getActiveProgressChannel(): Channel<BackEventCompat> {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a TIL question. What is the benefit of using Channel instead of Flow these days? Is it for performance?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just a lighter low-level abstraction.

val currentProgressChannel = progressChannel
if (currentProgressChannel == null) {
val progress = Channel<BackEventCompat>()
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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can listen to the state passed to NavigationBackHandler down below, which is local to this handler, instead of listening to dispatcher.transitionState. As there may be several handlers in a composition and this handler may not be the active one.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good idea, I'll try tomorrow

.filterIsInstance<NavigationEventTransitionState.InProgress>()
.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
)
}
1 change: 1 addition & 0 deletions compose/ui/ui-test/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ if (AndroidXComposePlugin.isMultiplatformEnabled(project)) {
dependencies {
implementation(libs.atomicFu)
implementation(project(":lifecycle:lifecycle-runtime-compose"))
implementation(project(":navigationevent:navigationevent-compose"))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -474,6 +480,7 @@ open class SkikoComposeUiTest @InternalTestApi constructor(
}

override val lifecycle = LifecycleRegistry.createUnsafe(this)
override val navigationEventDispatcher = NavigationEventDispatcher()

fun captureToImage(semanticsNode: SemanticsNode): ImageBitmap =
[email protected](semanticsNode)
Expand Down Expand Up @@ -535,6 +542,7 @@ open class SkikoComposeUiTest @InternalTestApi constructor(
private fun ProvideCommonCompositionLocals(content: @Composable () -> Unit) {
CompositionLocalProvider(
LocalLifecycleOwner provides testOwner,
LocalNavigationEventDispatcherOwner provides testOwner,
content = content,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -210,30 +187,29 @@ TODO: https://youtrack.jetbrains.com/issue/CMP-8937
if (edge == UIRectEdgeLeft) [email protected] else [email protected]
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()
}
}
}
}
}

UIGestureRecognizerStateCancelled -> {
dispatchOnCancelled()
dispatchOnBackCancelled()
}

UIGestureRecognizerStateFailed -> {
dispatchOnCompleted()
dispatchOnBackCompleted()
}
}
}
}
*/
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -322,6 +325,7 @@ internal class ComposeWindow(

override val lifecycle = LifecycleRegistry(this)
override val viewModelStore = ViewModelStore()
override val navigationEventDispatcher = NavigationEventDispatcher()

private fun <T : Event> addTypedEvent(
type: String,
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion navigation3/navigation3-ui/api/desktop/navigation3-ui.api
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <T : Any> defaultTransitionSpec():
AnimatedContentTransitionScope<Scene<T>>.() -> ContentTransform = implementedInJetBrainsFork()
AnimatedContentTransitionScope<Scene<T>>.() -> ContentTransform = {
ContentTransform(
fadeIn(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)),
fadeOut(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)),
)
}

public actual fun <T : Any> defaultPopTransitionSpec():
AnimatedContentTransitionScope<Scene<T>>.() -> ContentTransform = implementedInJetBrainsFork()
AnimatedContentTransitionScope<Scene<T>>.() -> ContentTransform = {
ContentTransform(
fadeIn(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)),
fadeOut(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)),
)
}

public actual fun <T : Any> defaultPredictivePopTransitionSpec():
AnimatedContentTransitionScope<Scene<T>>.(@NavigationEvent.SwipeEdge Int) -> ContentTransform =
implementedInJetBrainsFork()
AnimatedContentTransitionScope<Scene<T>>.(@SwipeEdge Int) -> ContentTransform = {
ContentTransform(
fadeIn(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)),
fadeOut(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)),
)
}
Loading