Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
109 changes: 105 additions & 4 deletions Sources/SkipUI/SkipUI/Containers/Navigation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<NavKey>()()
},
popTransitionSpec: {
// SKIP INSERT: slideInHorizontally { -it } + fadeIn() togetherWith slideOutHorizontally { it } + fadeOut()
transitions.popTransitionSpec ?? defaultPopTransitionSpec<NavKey>()()
},
predictivePopTransitionSpec: { _ in
// SKIP INSERT: slideInHorizontally { -it } + fadeIn() togetherWith slideOutHorizontally { it } + fadeOut()
predictivePopTransitionSpec: { edge in
transitions.predictivePopTransitionSpec ?? defaultPredictivePopTransitionSpec<NavKey>()(edge)
},
entryDecorators: decoratorList,
entryProvider: entryProvider
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 = [:]

Expand Down
19 changes: 19 additions & 0 deletions Sources/SkipUI/SkipUI/Containers/TabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand All @@ -524,6 +529,15 @@ public struct TabView : View, Renderable {
activeStack.removeLastOrNull()
}
},
transitionSpec: {
transitions.transitionSpec ?? defaultTransitionSpec<NavKey>()()
},
popTransitionSpec: {
transitions.popTransitionSpec ?? defaultPopTransitionSpec<NavKey>()()
},
predictivePopTransitionSpec: { edge in
transitions.predictivePopTransitionSpec ?? defaultPredictivePopTransitionSpec<NavKey>()(edge)
}
)
}
)
Expand Down Expand Up @@ -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
}

Expand Down
10 changes: 10 additions & 0 deletions Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? }
Expand Down Expand Up @@ -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 }) }
Expand Down