diff --git a/README.md b/README.md index c7f7c1dd..6a1dc7e0 100644 --- a/README.md +++ b/README.md @@ -3090,6 +3090,99 @@ struct CityPickerView: View { } ``` +#### Configure navigation transition animations + +SkipUI uses [Navigation 3](https://developer.android.com/guide/navigation/navigation-3) transition animations to animate pushing/popping in `NavigationStack` and to animate switching tabs in `TabView`. For `TabView`, we use Navigation 3's default animation (a quick fade), and for `NavigationStack`, we simulate SwiftUI's default animation with a slide + fade animation. + +You can configure these navigation animations with the `.navigationStackTransitions` and `.tabViewTransitions` modifiers. You pass it a closure that returns a `NavDisplayTransitionOptions` object. + +```swift +public struct NavDisplayTransitionOptions { + public enum TransitionPreset { + case `default` // `default` uses NavDisplay.defaultTransitionSpec, .defaultPopTransitionSpec, and .defaultPredictivePopTransitionSpec + case slideIn + case slideOut + case fade + case slideInAndFade // This is the default NavigationStack transition + case slideOutAndFade + case none // instant transition with no animation + } + + public init( + transition: TransitionPreset = .default, + popTransition: TransitionPreset = .default, + predictivePopTransition: TransitionPreset = .default + ) + + public init(_ transitionPresets: TransitionPreset) + + public func copy( + transition: TransitionPreset? = nil, + popTransition: TransitionPreset? = nil, + predictivePopTransition: TransitionPreset? = nil + ) -> NavDisplayTransitionOptions + + #if SKIP + public let transitionSpec: ContentTransform? + public let popTransitionSpec: ContentTransform? + public let predictivePopTransitionSpec: ContentTransform? + + public init( + transitionSpec: ContentTransform?, + popTransitionSpec: ContentTransform?, + predictivePopTransitionSpec: ContentTransform? + ) + + public init(_ transitionSpecs: ContentTransform?) + + public func copy( + transitionSpec: ContentTransform? = self.transitionSpec, + popTransitionSpec: ContentTransform? = self.popTransitionSpec, + predictivePopTransitionSpec: ContentTransform? = self.predictivePopTransitionSpec + ) -> NavDisplayTransitionOptions + #endif +} +``` + +Your closure can either return your own instance of `NavDisplayTransitionOptions`, or a copy of the provided options, changing just one of the transitions. + +```swift +#if os(Android) +.tabViewTransitions { options in + NavDisplayTransitionOptions(.none) +} +.navigationStackTransitions { _ in + options.copy(predictivePopTransition: .fade) +} +#endif +``` + +In transpiled `#if SKIP` code, you can specify a custom animation spec. (In Skip Fuse, you'd use [`composeModifier`](#composemodifier) for that.) + +```swift +#if os(Android) +.composeModifier { + CustomNavigationAnimationSpecModifier() +} +#endif + +// ... + +#if SKIP +struct CustomNavigationAnimationSpecModifier : ContentModifier { + func modify(view: any View) -> any View { + view.composeModifier { + $0.navigationStackTransitions { _ in + NavDisplayTransitionOptions( + // this example is just .slideInAndFade, but you could customize it however you like + (slideInHorizontally { $0 } + fadeIn()).togetherWith(slideOutHorizontally { -$0 } + fadeOut()) + ) + } + } + } +} +#endif +``` #### Modals Skip supports standard modal presentations. Android apps typically allow users to dismiss modals with the Android back button. Skip allows you to selectively disable this behavior with the Android-only `backDismissDisabled(_ isDisabled: Bool = true)` SwiftUI modifier. If you use this modifier, you **must** put it on the top-level view embedded in your `.sheet` or `.fullScreenCover`, as in the following example: diff --git a/Sources/SkipUI/SkipUI/Containers/Navigation.swift b/Sources/SkipUI/SkipUI/Containers/Navigation.swift index 9d287975..c0978c6c 100644 --- a/Sources/SkipUI/SkipUI/Containers/Navigation.swift +++ b/Sources/SkipUI/SkipUI/Containers/Navigation.swift @@ -4,6 +4,8 @@ import Foundation #if SKIP import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -88,7 +90,11 @@ import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.scene.Scene import androidx.navigation3.ui.NavDisplay +import androidx.navigation3.ui.defaultPopTransitionSpec +import androidx.navigation3.ui.defaultPredictivePopTransitionSpec +import androidx.navigation3.ui.defaultTransitionSpec import kotlin.reflect.full.superclasses import kotlinx.serialization.Serializable import androidx.compose.runtime.key @@ -198,18 +204,20 @@ public struct NavigationStack : View, Renderable { } } } + let defaults = NavDisplayTransitionOptions.navigationStackDefaults + let transitions = EnvironmentValues.shared._navigationStackTransitions?(defaults) ?? defaults NavDisplay( backStack: navBackStack, modifier: modifier, onBack: { navigator.value.navigateBack() }, transitionSpec: { - // SKIP INSERT: slideInHorizontally { it } + fadeIn() togetherWith slideOutHorizontally { -it } + fadeOut() + transitions.transitionSpec ?? defaultTransitionSpec()() }, popTransitionSpec: { - // SKIP INSERT: slideInHorizontally { -it } + fadeIn() togetherWith slideOutHorizontally { it } + fadeOut() + transitions.popTransitionSpec ?? defaultPopTransitionSpec()() }, - predictivePopTransitionSpec: { _ in - // SKIP INSERT: slideInHorizontally { -it } + fadeIn() togetherWith slideOutHorizontally { it } + fadeOut() + predictivePopTransitionSpec: { edge in + transitions.predictivePopTransitionSpec ?? defaultPredictivePopTransitionSpec()(edge) }, entryDecorators: decoratorList, entryProvider: entryProvider @@ -1379,6 +1387,11 @@ extension View { return environment(\._material3BottomAppBar, options, affectsEvaluate: false) } + // SKIP @bridge + public func navigationStackTransitions(_ options: @escaping (NavDisplayTransitionOptions) -> NavDisplayTransitionOptions) -> View { + return environment(\._navigationStackTransitions, options, affectsEvaluate: false) + } + public func navigationStackLayoutHints(expectedTitle: Text = NavigationTitlePreferenceKey.defaultValue, expectedTitleDisplayMode: ToolbarTitleDisplayMode? = nil) -> View { return environment( \._navigationStackLayoutHints, @@ -1509,6 +1522,111 @@ public struct Material3BottomAppBarOptions { } } +// SKIP @bridge +public struct NavDisplayTransitionOptions { + // SKIP @bridgeMembers + public enum TransitionPreset: Sendable { + case `default` + case slideIn + case slideOut + case slideInAndFade + case slideOutAndFade + case fade + case none + + // SKIP @nobridge + var contentTransform: ContentTransform? { + switch self { + case .default: + // This will use NavDisplay.defaultTransitionSpec, .defaultPopTransitionSpec, and .defaultPredictivePopTransitionSpec + return nil + case .slideIn: + return slideInHorizontally { $0 }.togetherWith(slideOutHorizontally { -$0 }) + case .slideOut: + return slideInHorizontally { -$0 }.togetherWith(slideOutHorizontally { $0 }) + case .slideInAndFade: + return (slideInHorizontally { $0 } + fadeIn()).togetherWith(slideOutHorizontally { -$0 } + fadeOut()) + case .slideOutAndFade: + return (slideInHorizontally { -$0 } + fadeIn()).togetherWith(slideOutHorizontally { $0 } + fadeOut()) + case .fade: + return fadeIn().togetherWith(fadeOut()) + case .none: + return fadeIn(animationSpec: tween(0)).togetherWith(fadeOut(animationSpec: tween(0))) + } + } + } + + public let transitionSpec: ContentTransform? + public let popTransitionSpec: ContentTransform? + public let predictivePopTransitionSpec: ContentTransform? + + public init(transitionSpec: ContentTransform?, popTransitionSpec: ContentTransform?, predictivePopTransitionSpec: ContentTransform?) { + self.transitionSpec = transitionSpec + self.popTransitionSpec = popTransitionSpec + self.predictivePopTransitionSpec = predictivePopTransitionSpec + } + + public init(_ transitionSpecs: ContentTransform?) { + self.transitionSpec = transitionSpecs + self.popTransitionSpec = transitionSpecs + self.predictivePopTransitionSpec = transitionSpecs + } + + // SKIP @bridge + public init(_ transitionPreset: TransitionPreset) { + self.transitionSpec = transitionPreset.contentTransform + self.popTransitionSpec = transitionPreset.contentTransform + self.predictivePopTransitionSpec = transitionPreset.contentTransform + } + + // SKIP @bridge + public init(transition: TransitionPreset = .default, popTransition: TransitionPreset = .default, predictivePopTransition: TransitionPreset = .default) { + self.transitionSpec = transition.contentTransform + self.popTransitionSpec = popTransition.contentTransform + self.predictivePopTransitionSpec = predictivePopTransition.contentTransform + } + + // SKIP @bridge + public static var navigationStackDefaults: NavDisplayTransitionOptions { + NavDisplayTransitionOptions( + transition: .slideInAndFade, + popTransition: .slideOutAndFade, + predictivePopTransition: .slideOut + ) + } + + // SKIP @bridge + public static var tabViewDefaults: NavDisplayTransitionOptions { + NavDisplayTransitionOptions() + } + + public func copy( + transitionSpec: ContentTransform? = self.transitionSpec, + popTransitionSpec: ContentTransform? = self.popTransitionSpec, + predictivePopTransitionSpec: ContentTransform? = self.predictivePopTransitionSpec + ) -> NavDisplayTransitionOptions { + NavDisplayTransitionOptions( + transitionSpec: transitionSpec, + popTransitionSpec: popTransitionSpec, + predictivePopTransitionSpec: predictivePopTransitionSpec + ) + } + + /// Preset-based updates. Pass `nil` for a slot to leave `self`’s value unchanged. + // SKIP @bridge + public func copy( + transition: TransitionPreset? = nil, + popTransition: TransitionPreset? = nil, + predictivePopTransition: TransitionPreset? = nil + ) -> NavDisplayTransitionOptions { + return copy( + transitionSpec: transition == .default ? nil : (transition?.contentTransform ?? transitionSpec), + popTransitionSpec: popTransition == .default ? nil : (popTransition?.contentTransform ?? popTransitionSpec), + predictivePopTransitionSpec: predictivePopTransition == .default ? nil : (predictivePopTransition?.contentTransform ?? predictivePopTransitionSpec) + ) + } +} + struct NavigationDestinationsPreferenceKey: PreferenceKey { static let defaultValue: NavigationDestinations = [:] diff --git a/Sources/SkipUI/SkipUI/Containers/TabView.swift b/Sources/SkipUI/SkipUI/Containers/TabView.swift index ab2f7f54..647534b8 100644 --- a/Sources/SkipUI/SkipUI/Containers/TabView.swift +++ b/Sources/SkipUI/SkipUI/Containers/TabView.swift @@ -78,6 +78,9 @@ import androidx.navigation3.runtime.rememberDecoratedNavEntries import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay +import androidx.navigation3.ui.defaultPopTransitionSpec +import androidx.navigation3.ui.defaultPredictivePopTransitionSpec +import androidx.navigation3.ui.defaultTransitionSpec import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.serialization.Serializable @@ -515,6 +518,8 @@ public struct TabView : View, Renderable { tabIndex += 1 } let activeEntries = decoratedEntrySlots[selectedTabIndex.value]! + let defaults = NavDisplayTransitionOptions.tabViewDefaults + let transitions = EnvironmentValues.shared._tabViewTransitions?(defaults) ?? defaults NavDisplay( entries: activeEntries, modifier: Modifier.fillMaxSize(), @@ -523,6 +528,15 @@ public struct TabView : View, Renderable { activeStack.removeLastOrNull() } }, + transitionSpec: { + transitions.transitionSpec ?? defaultTransitionSpec()() + }, + popTransitionSpec: { + transitions.popTransitionSpec ?? defaultPopTransitionSpec()() + }, + predictivePopTransitionSpec: { edge in + transitions.predictivePopTransitionSpec ?? defaultPredictivePopTransitionSpec()(edge) + } ) } ) @@ -1234,6 +1248,11 @@ extension View { public func material3NavigationBar(_ options: @Composable (Material3NavigationBarOptions) -> Material3NavigationBarOptions) -> View { return environment(\._material3NavigationBar, options, affectsEvaluate: false) } + + // SKIP @bridge + public func tabViewTransitions(_ options: @escaping (NavDisplayTransitionOptions) -> NavDisplayTransitionOptions) -> View { + return environment(\._tabViewTransitions, options, affectsEvaluate: false) + } #endif } diff --git a/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift b/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift index 5f83c841..f623e428 100644 --- a/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift +++ b/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift @@ -796,6 +796,11 @@ extension EnvironmentValues { set { setBuiltinValue(key: "_navigationStackLayoutHints", value: newValue, defaultValue: { nil }) } } + var _navigationStackTransitions: (@escaping (NavDisplayTransitionOptions) -> NavDisplayTransitionOptions)? { + get { builtinValue(key: "_navigationStackTransitions", defaultValue: { nil }) as! (@escaping (NavDisplayTransitionOptions) -> NavDisplayTransitionOptions)? } + set { setBuiltinValue(key: "_navigationStackTransitions", value: newValue, defaultValue: { nil }) } + } + /// Nested scroll connection for the active `NavigationStack` entry's top app bar public var _nestedScrollConnection: NestedScrollConnection? { get { builtinValue(key: "_nestedScrollConnection", defaultValue: { nil }) as! NestedScrollConnection? } @@ -827,6 +832,11 @@ extension EnvironmentValues { set { setBuiltinValue(key: "_tabViewStyle", value: newValue, defaultValue: { nil }) } } + var _tabViewTransitions: (@escaping (NavDisplayTransitionOptions) -> NavDisplayTransitionOptions)? { + get { builtinValue(key: "_tabViewTransitions", defaultValue: { nil }) as! (@escaping (NavDisplayTransitionOptions) -> NavDisplayTransitionOptions)? } + set { setBuiltinValue(key: "_tabViewTransitions", value: newValue, defaultValue: { nil }) } + } + var _safeArea: SafeArea? { get { builtinValue(key: "_safeArea", defaultValue: { nil }) as! SafeArea? } set { setBuiltinValue(key: "_safeArea", value: newValue, defaultValue: { nil }) }