Skip to content

Commit

Permalink
Navigation link customization (#1491)
Browse files Browse the repository at this point in the history
* Add phx-replace option

* Update changelog

* Add destination template to NavigationLink
  • Loading branch information
carson-katri authored Dec 11, 2024
1 parent dddcae5 commit ab2fd52
Show file tree
Hide file tree
Showing 6 changed files with 100 additions and 18 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions Sources/LiveViewNative/Coordinators/LiveNavigationEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
//

import Foundation
import SwiftUI

public struct LiveNavigationEntry<R: RootRegistry>: Hashable {
public let url: URL
public let coordinator: LiveViewCoordinator<R>
let navigationTransition: Any?
let pendingView: (any View)?

public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.url == rhs.url && lhs.coordinator === rhs.coordinator
Expand Down
17 changes: 11 additions & 6 deletions Sources/LiveViewNative/Coordinators/LiveSessionCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ public class LiveSessionCoordinator<R: RootRegistry>: 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) })
Expand Down Expand Up @@ -212,7 +212,8 @@ public class LiveSessionCoordinator<R: RootRegistry>: ObservableObject {
self.navigationPath[self.navigationPath.endIndex - 1] = .init(
url: responseURL,
coordinator: self.navigationPath.last!.coordinator,
navigationTransition: nil
navigationTransition: nil,
pendingView: nil
)
url = responseURL
} else {
Expand Down Expand Up @@ -308,7 +309,7 @@ public class LiveSessionCoordinator<R: RootRegistry>: 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)
}
Expand Down Expand Up @@ -524,11 +525,15 @@ public class LiveSessionCoordinator<R: RootRegistry>: 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)
Expand All @@ -547,7 +552,7 @@ public class LiveSessionCoordinator<R: RootRegistry>: 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)
Expand Down
2 changes: 1 addition & 1 deletion Sources/LiveViewNative/Live/LiveView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ struct PhxMain<R: RootRegistry>: View {
@EnvironmentObject private var session: LiveSessionCoordinator<R>

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))
}
}

Expand Down
15 changes: 13 additions & 2 deletions Sources/LiveViewNative/NavStackEntryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,19 @@ struct NavStackEntryView<R: RootRegistry>: 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)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,34 @@ private let logger = Logger(subsystem: "LiveViewNative", category: "NavigationLi
/// </NavigationLink>
/// ```
///
/// Use the `phx-replace` attribute to do a replace navigation instead of a push.
/// This will replace the current route with the destination.
///
/// ```html
/// <NavigationLink phx-replace destination={"/products/#{@product.id}"}>
/// <Text>More Information</Text>
/// </NavigationLink>
/// ```
///
/// 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
/// <NavigationLink destination={"/products/#{@product.id}"}>
/// <Text>More Information</Text>
///
/// <ProgressView
/// template="destination"
/// style='navigationTitle(attr("title"));'
/// title={@product.title}
/// />
/// </NavigationLink>
/// ```
///
/// ## Attributes
/// - ``destination``
/// - ``disabled``
/// - ``replace``
@_documentation(visibility: public)
@available(iOS 16.0, *)
@LiveElement
Expand All @@ -32,21 +57,58 @@ struct NavigationLink<Root: RootRegistry>: 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()
Expand Down

0 comments on commit ab2fd52

Please sign in to comment.