diff --git a/README.md b/README.md index c7f7c1dd..955ee08d 100644 --- a/README.md +++ b/README.md @@ -3090,6 +3090,91 @@ 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 slide + case fade + case slideAndFade // This is the default NavigationStack transition + case none // instant transition with no animation + } + + 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? = nil, + popTransitionSpec: ContentTransform? = nil, + predictivePopTransitionSpec: ContentTransform? = nil + ) + + 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 .slideAndFade, 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 a3f1d8d3..667ba25c 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.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally @@ -85,7 +87,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 @@ -205,18 +211,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 @@ -1383,6 +1391,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, @@ -1513,6 +1526,94 @@ public struct Material3BottomAppBarOptions { } } +// SKIP @bridge +public struct NavDisplayTransitionOptions { + // SKIP @bridgeMembers + public enum TransitionPreset: Sendable { + case `default` + case slide + case slideAndFade + case fade + case none + + // SKIP @nobridge + var contentTransform: ContentTransform? { + switch self { + case .default: + // This will use NavDisplay.defaultTransitionSpec, .defaultPopTransitionSpec, and .defaultPredictivePopTransitionSpec + return nil + case .slide: + return slideInHorizontally { $0 }.togetherWith(slideOutHorizontally { -$0 }) + case .slideAndFade: + 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? = nil, popTransitionSpec: ContentTransform? = nil, predictivePopTransitionSpec: ContentTransform? = nil) { + 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 static var navigationStackDefaults: NavDisplayTransitionOptions { + NavDisplayTransitionOptions(.slideAndFade) + } + + // 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 a408872a..8ae171e2 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 @@ -516,6 +519,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(), @@ -524,6 +529,15 @@ public struct TabView : View, Renderable { activeStack.removeLastOrNull() } }, + transitionSpec: { + transitions.transitionSpec ?? defaultTransitionSpec()() + }, + popTransitionSpec: { + transitions.popTransitionSpec ?? defaultPopTransitionSpec()() + }, + predictivePopTransitionSpec: { edge in + transitions.predictivePopTransitionSpec ?? defaultPredictivePopTransitionSpec()(edge) + } ) } ) @@ -1235,6 +1249,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 315f111a..16868115 100644 --- a/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift +++ b/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift @@ -791,6 +791,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? } @@ -822,6 +827,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 }) }