diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f0d8f706..31b88623f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - LiveViewNative.SwiftUI.Client +- `NavigationLink` can perform a replace navigation by adding the `phx-replace` attribute +- `NavigationLink` takes a `destination` template to customize the connecting phase View for its navigation event. ### Changed diff --git a/Sources/LiveViewNative/Coordinators/LiveNavigationEntry.swift b/Sources/LiveViewNative/Coordinators/LiveNavigationEntry.swift index 57ac66ca0..446fcd1e3 100644 --- a/Sources/LiveViewNative/Coordinators/LiveNavigationEntry.swift +++ b/Sources/LiveViewNative/Coordinators/LiveNavigationEntry.swift @@ -6,11 +6,13 @@ // import Foundation +import SwiftUI public struct LiveNavigationEntry: Hashable { public let url: URL public let coordinator: LiveViewCoordinator let navigationTransition: Any? + let pendingView: (any View)? public static func == (lhs: Self, rhs: Self) -> Bool { lhs.url == rhs.url && lhs.coordinator === rhs.coordinator diff --git a/Sources/LiveViewNative/Coordinators/LiveSessionCoordinator.swift b/Sources/LiveViewNative/Coordinators/LiveSessionCoordinator.swift index c284af983..667b18723 100644 --- a/Sources/LiveViewNative/Coordinators/LiveSessionCoordinator.swift +++ b/Sources/LiveViewNative/Coordinators/LiveSessionCoordinator.swift @@ -110,7 +110,7 @@ public class LiveSessionCoordinator: ObservableObject { delegateQueue: nil ) - self.navigationPath = [.init(url: url, coordinator: .init(session: self, url: self.url), navigationTransition: nil)] + self.navigationPath = [.init(url: url, coordinator: .init(session: self, url: self.url), navigationTransition: nil, pendingView: nil)] self.mergedEventSubjects = self.navigationPath.first!.coordinator.eventSubject.compactMap({ [weak self] value in self.map({ ($0.navigationPath.first!.coordinator, value) }) @@ -212,7 +212,8 @@ public class LiveSessionCoordinator: ObservableObject { self.navigationPath[self.navigationPath.endIndex - 1] = .init( url: responseURL, coordinator: self.navigationPath.last!.coordinator, - navigationTransition: nil + navigationTransition: nil, + pendingView: nil ) url = responseURL } else { @@ -308,7 +309,7 @@ public class LiveSessionCoordinator: ObservableObject { if let url { await self.disconnect(preserveNavigationPath: false) self.url = url - self.navigationPath = [.init(url: self.url, coordinator: self.navigationPath.first!.coordinator, navigationTransition: nil)] + self.navigationPath = [.init(url: self.url, coordinator: self.navigationPath.first!.coordinator, navigationTransition: nil, pendingView: nil)] } else { await self.disconnect(preserveNavigationPath: true) } @@ -524,11 +525,15 @@ public class LiveSessionCoordinator: ObservableObject { } } - func redirect(_ redirect: LiveRedirect) async throws { + func redirect( + _ redirect: LiveRedirect, + navigationTransition: Any? = nil, + pendingView: (any View)? = nil + ) async throws { switch redirect.mode { case .replaceTop: let coordinator = LiveViewCoordinator(session: self, url: redirect.to) - let entry = LiveNavigationEntry(url: redirect.to, coordinator: coordinator, navigationTransition: nil) + let entry = LiveNavigationEntry(url: redirect.to, coordinator: coordinator, navigationTransition: navigationTransition, pendingView: pendingView) switch redirect.kind { case .push: navigationPath.append(entry) @@ -547,7 +552,7 @@ public class LiveSessionCoordinator: ObservableObject { // patch is like `replaceTop`, but it does not disconnect. let coordinator = navigationPath.last!.coordinator coordinator.url = redirect.to - let entry = LiveNavigationEntry(url: redirect.to, coordinator: coordinator, navigationTransition: nil) + let entry = LiveNavigationEntry(url: redirect.to, coordinator: coordinator, navigationTransition: navigationTransition, pendingView: pendingView) switch redirect.kind { case .push: navigationPath.append(entry) diff --git a/Sources/LiveViewNative/Live/LiveView.swift b/Sources/LiveViewNative/Live/LiveView.swift index 9291c736b..d96a6a9d4 100644 --- a/Sources/LiveViewNative/Live/LiveView.swift +++ b/Sources/LiveViewNative/Live/LiveView.swift @@ -241,7 +241,7 @@ struct PhxMain: View { @EnvironmentObject private var session: LiveSessionCoordinator var body: some View { - NavStackEntryView(.init(url: context.coordinator.url, coordinator: context.coordinator, navigationTransition: nil)) + NavStackEntryView(.init(url: context.coordinator.url, coordinator: context.coordinator, navigationTransition: nil, pendingView: nil)) } } diff --git a/Sources/LiveViewNative/NavStackEntryView.swift b/Sources/LiveViewNative/NavStackEntryView.swift index 49bdb8f2f..7a7562d0f 100644 --- a/Sources/LiveViewNative/NavStackEntryView.swift +++ b/Sources/LiveViewNative/NavStackEntryView.swift @@ -55,8 +55,19 @@ struct NavStackEntryView: View { .transition(coordinator.session.configuration.transition ?? .identity) .id(ObjectIdentifier(document)) } else { - buildPhaseView(phase) - .transition(coordinator.session.configuration.transition ?? .identity) + switch phase { + case .connecting: + if let pendingView = entry.pendingView { + AnyView(pendingView) + .transition(coordinator.session.configuration.transition ?? .identity) + } else { + buildPhaseView(phase) + .transition(coordinator.session.configuration.transition ?? .identity) + } + default: + buildPhaseView(phase) + .transition(coordinator.session.configuration.transition ?? .identity) + } } } } diff --git a/Sources/LiveViewNative/Views/Layout Containers/Presentation Containers/NavigationLink.swift b/Sources/LiveViewNative/Views/Layout Containers/Presentation Containers/NavigationLink.swift index 4fe997437..91cc1acda 100644 --- a/Sources/LiveViewNative/Views/Layout Containers/Presentation Containers/NavigationLink.swift +++ b/Sources/LiveViewNative/Views/Layout Containers/Presentation Containers/NavigationLink.swift @@ -21,9 +21,34 @@ private let logger = Logger(subsystem: "LiveViewNative", category: "NavigationLi /// /// ``` /// +/// Use the `phx-replace` attribute to do a replace navigation instead of a push. +/// This will replace the current route with the destination. +/// +/// ```html +/// +/// More Information +/// +/// ``` +/// +/// Provide a `destination` template View to customize the View used when transitioning between pages. +/// This is often used to set the navigation title before transitioning to reduce visual hitches. +/// If no destination template is provided, the app will use its global connecting phase view. +/// +/// ```html +/// +/// More Information +/// +/// +/// +/// ``` +/// /// ## Attributes /// - ``destination`` -/// - ``disabled`` +/// - ``replace`` @_documentation(visibility: public) @available(iOS 16.0, *) @LiveElement @@ -32,21 +57,58 @@ struct NavigationLink: View { @_documentation(visibility: public) private var destination: String? + @LiveAttribute("phx-replace") + private var replace: Bool = false + @LiveElementIgnored @Environment(\._anyNavigationTransition) private var anyNavigationTransition: Any? + @LiveElementIgnored + @Environment(\.anyLiveContextStorage) + private var anyLiveContextStorage: Any? + + @LiveElementIgnored + @Environment(\.coordinatorEnvironment) + private var coordinatorEnvironment: CoordinatorEnvironment? + @ViewBuilder public var body: some View { if let url = destination.flatMap({ URL(string: $0, relativeTo: $liveElement.context.coordinator.url) })?.appending(path: "").absoluteURL { - SwiftUI.NavigationLink( - value: LiveNavigationEntry( - url: url, - coordinator: LiveViewCoordinator(session: $liveElement.context.coordinator.session, url: url), - navigationTransition: anyNavigationTransition - ) - ) { - $liveElement.children() + let pendingView: (some View)? = if $liveElement.hasTemplate("destination") { + $liveElement.children(in: "destination") + .environment(\.anyLiveContextStorage, anyLiveContextStorage) + .environment(\.coordinatorEnvironment, coordinatorEnvironment) + } else { + nil + } + if replace { + SwiftUI.Button { + Task { @MainActor in + try await $liveElement.context.coordinator.session.redirect( + .init( + kind: .replace, + to: url, + mode: .replaceTop + ), + navigationTransition: anyNavigationTransition, + pendingView: pendingView + ) + } + } label: { + $liveElement.children() + } + } else { + SwiftUI.NavigationLink( + value: LiveNavigationEntry( + url: url, + coordinator: LiveViewCoordinator(session: $liveElement.context.coordinator.session, url: url), + navigationTransition: anyNavigationTransition, + pendingView: pendingView + ) + ) { + $liveElement.children() + } } } else { $liveElement.children()