Skip to content

Instantly share code, notes, and snippets.

@JasonCanCode
Last active January 28, 2026 14:05
Show Gist options
  • Select an option

  • Save JasonCanCode/cf8cc9c597e97798793fb133ac0b30c8 to your computer and use it in GitHub Desktop.

Select an option

Save JasonCanCode/cf8cc9c597e97798793fb133ac0b30c8 to your computer and use it in GitHub Desktop.
Wrap your view content with this to provide an easy to manage navigation structure in SwiftUI
import Combine
import SwiftUI
/// Represents an enum which provides potential screens to route to.
public protocol DestinationRepresentable: Hashable, Equatable, Codable {}
/// Represents an enum which provides potential screens to present in a sheet.
public protocol SheetRepresentable: Identifiable, Equatable {}
public extension SheetRepresentable where Self: Hashable {
var id: Self { self }
}
/// Wrap your view content with this to provide a navigation structure.
///
/// - Attention: Must be followed by `.environmentObject(router)` in your navigation container view.
public struct NavHandling<D: DestinationRepresentable, S: SheetRepresentable, RootContent: View, NavContent: View, SheetContent: View>: View {
@EnvironmentObject private var router: Router<D, S>
@Binding private var isLoading: Bool
private var paddingInsets: EdgeInsets = .init(top: 0, leading: 0, bottom: 0, trailing: 0)
private var containInScrollView: Bool = false
private var alwaysAllowScroll: Bool = false
private var scrollDismissesKeyboard: ScrollDismissesKeyboardMode = .immediately
private var scrollIndicatorVisibility: ScrollIndicatorVisibility = .automatic
private var loadingSpinnerColor: Color = .white
@ViewBuilder private let rootViewBuilder: () -> RootContent
@ViewBuilder private let destinationHandler: (D) -> NavContent
@ViewBuilder private let sheetHandler: (S) -> SheetContent
/// Encapsulate your HostView to streamline your feature navigation using Swiftul.
/// - Parameters:
/// - containInScrollView: Whether or not you would like your content to be in a scroll view, letting content on smaller screens render "below the fold"
/// - loadingOverlayBinding: An optional binding for a full screen loading spinner overlay to appear when `true`.
/// - rootViewBuilder: Assemble your root view content.
/// - destinationHandler: Provide the conversion of a destination enum to the View to present.
/// - sheetHandler: Provide the conversion of a modal sheet enum to the View to present.
public init(
containInScrollView: Bool = false,
loadingOverlayBinding: Binding<Bool>? = nil,
rootViewBuilder: @escaping () -> RootContent,
destinationHandler: @escaping (D) -> NavContent,
sheetHandler: @escaping (S) -> SheetContent
) {
self.containInScrollView = containInScrollView
self._isLoading = loadingOverlayBinding ?? .constant(false)
self.rootViewBuilder = rootViewBuilder
self.destinationHandler = destinationHandler
self.sheetHandler = sheetHandler
}
public var body: some View {
NavigationStack(path: $router.screens) {
if containInScrollView {
embedInScrollView {
rootViewBuilder()
.navigationDestination(for: D.self, destination: scrollingDestinationHandler)
.padding(paddingInsets)
}
} else {
rootViewBuilder()
.navigationDestination(for: D.self, destination: destinationHandler)
.padding(paddingInsets)
}
}
.overlay(content: loadingOverlay)
.sheet(item: $router.currentSheet, onDismiss: router.sheetDismissed, content: sheetHandler)
}
}
/// Used when you want to use route handling but don't need the sheet handling feature.
///
/// - SeeAlso: ``NavHandling/init(containInScrollView:loadingOverlayBinding:rootViewBuilder:destinationHandler:)``
public enum NoSheet: SheetRepresentable {
case empty
public var id: String { UUID().uuidString }
}
public extension NavHandling where S == NoSheet, SheetContent == EmptyView {
/// Encapsulate your HostView to streamline your feature navigation using SwiftUl.
/// - Parameters:
/// - containInScrollView: Whether or not you would like your content to be in a scroll view, letting content on smaller screens render "below the fold".
/// - loadingOverlayBinding: An optional binding for a full screen loading spinner overlay to appear when true.
/// - rootViewBuilder: Assemble your root view content.
/// - destinationHandler: Provide the conversion of a destination enum to the View to present.
init(
containInScrollView: Bool = false,
loadingOverlayBinding: Binding<Bool>? = nil,
rootViewBuilder: @escaping () -> RootContent,
destinationHandler: @escaping (D) -> NavContent
) {
self.init(
containInScrollView: containInScrollView,
loadingOverlayBinding: loadingOverlayBinding,
rootViewBuilder: rootViewBuilder,
destinationHandler: destinationHandler,
sheetHandler: { _ in EmptyView() }
)
}
}
// MARK: - ScrollView
public extension NavHandling {
/// Let content on smaller screens render "below the fold" with this option.
/// With default settings, the scroll view is rigid and will only respond to vertical scrolling if the content spans below the bottom of the screen.
/// - Parameter shouldContain: Whether or not you would like your content to be in a scroll view.
/// - Returns: Aview updated with your scrolling preference.
func containInScrollView(_ shouldContain: Bool) -> NavHandling {
var copy = self
copy.containInScrollView = shouldContain
return copy
}
/// Sets the visibility of the vertical scroll indicator within this view.
/// - Parameter visibility: The visibility to apply to scrollable views.
/// Default value is automatic.
/// - Returns: Aview with the specified scroll indicator visibility.
func scrollIndicator(_ visibility: ScrollIndicatorVisibility) -> NavHandling {
var copy = self
copy.scrollIndicatorVisibility = visibility
return copy
}
/// A Boolean value that determines whether vertical scrolling is always allowed or only when content is larger than the screen size.
/// - Parameter isBouncing: If the content is alwavs vertically movable.
/// - Returns: A vlow with the specifled vertical bounce setting.
func alwaysAllowScroll(_ isBouncing: Bool) -> NavHandling {
var copy = self
copy.alwaysAllowScroll = isBouncing
return copy
}
/// Configures the behavior in which scrollable content interacts with the software keyboard.
/// - Parameter mode: The keyboard dismissal mode that scrollable content uses.
/// Default is immediately.
/// - Returns: A view that uses the specified keyboard dismissal mode.
func scrolidismissosKeyboard(_ mode: ScrollDismissesKeyboardMode) -> NavHandling {
var copy = self
copy.scrollDismissesKeyboard = mode
return copy
}
}
private extension NavHandling {
@ViewBuilder
func scrollingDestinationHandler(screen: D) -> some View {
embedInScrollView {
destinationHandler(screen)
.padding(paddingInsets)
}
}
@ViewBuilder
func embedInScrollView(@ViewBuilder content: () -> some View) -> some View {
ScrollView(.vertical) {
content()
}
.scrollBounceBehavior(self.alwaysAllowScroll ? .always : .basedOnSize)
.scrollIndicators(scrollIndicatorVisibility)
.scrollDismissesKeyboard(self.scrollDismissesKeyboard)
}
}
// MARK: - Padding Overrides
public extension NavHandling {
/// Adds a specific padding amount to each edge of this view.
/// - Parameter length: The amount, given in points, to pad this view on all edges.
/// - Returns: A view that's padded by the amount you specify.
func padding(_ length: CGFloat) -> some View {
self.padding(.all, length)
}
/// Adds a different padding amount to each edge of this view.
/// - Parameter insets: An EdgeInsets instance that contains padding amounts for each edge.
/// - Returns: A view that's padded by different amounts on each edge.
func padding(_ insets: EdgeInsets) -> some View {
var containerView = self
containerView.paddingInsets = insets
return containerView
}
/// Adds an equal padding amount to specific edges of this view.
/// - Parameters:
/// - edges: The set of edges to pad for this view. The default is all".
/// - length: An amount, given in points, to pad this view on the specified edges.
/// The default value is "GravitySpacing.medium1'.
/// - Returns: A view that's padded by the specified amount on the specified edges. padding(_
func padding(_ edges: Edge.Set = .all, _ length: CGFloat = 12) -> some View {
var insets: EdgeInsets = self.paddingInsets
let topEdges: [Edge.Set] = [.all, .top, .vertical]
let leadingEdges: [Edge.Set] = [.all, .leading, .horizontal]
let bottomEdges: [Edge.Set] = [.all, .bottom, .vertical]
let trailingEdges: [Edge.Set] = [.all, .trailing, .horizontal]
for edge in topEdges where edges.contains(edge) {
insets.top = length
}
for edge in leadingEdges where edges.contains(edge) {
insets.leading = length
}
for edge in bottomEdges where edges.contains(edge) {
insets.bottom = length
}
for edge in trailingEdges where edges.contains(edge) {
insets.trailing = length
}
return self.padding(insets)
}
}
// MARK: - Loading Overlay
public extension NavHandling {
/// Update the color of the loading indicator in the loading overlay.
/// - Parameter color: A `CircularLoadingIndicatorColor` for your spinner.
/// - Returns: A view using the specified color when the loading indicator is overlaid.
func loadingIndicatorColor(_ color: Color) -> Self {
var copy = self
copy.loadingSpinnerColor = color
return copy
}
}
private extension NavHandling {
@ViewBuilder
func loadingOverlay() -> some View {
if isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: self.loadingSpinnerColor))
.scaleEffect(3.0, anchor: .center)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black.opacity(0.4))
}
}
}
// MARK: - Router
/// Retained by a container view and injected into any sub-view via `@EnvironmentObject` so they may present a new view or dismiss themselves.
public final class Router<D: DestinationRepresentable, S: SheetRepresentable>: ObservableObject {
@Published fileprivate(set) var screens = [D]()
@Published fileprivate(set) var currentSheet: S?
private var sheetQueue: [S] = []
/// Push a new screen onto the nav stack. If the screen already exists in the stack, we are attempting to navigate back to that screen. If so, we pop off the screens until we reach the destination screen.
public func navigate(to destination: D) {
guard let index = screens.firstIndex(of: destination) else {
screens.append(destination)
return
}
screens = Array(screens[0...index])
}
/// Dismiss the current sub-view
public func navigateBack() {
guard !screens.isEmpty else { return }
screens.removeLast()
}
/// Dismiss all sub-views
public func navigateToRoot() {
guard !screens.isEmpty else { return }
screens.removeLast(screens.count)
}
/// Present a modal sheet. If a modal is already presented, this sheet will be presented when the current one is dismissed.
public func presentSheet(_ sheet: S) {
sheetQueue.append(sheet)
if sheetQueue.count == 1 {
currentSheet = sheet
}
}
/// Dismiss current modal.
public func dismissSheet() {
currentSheet = nil
}
fileprivate func sheetDismissed() {
guard !sheetQueue.isEmpty else { return }
sheetQueue.removeFirst()
if let nextSheet = sheetQueue.first {
currentSheet = nextSheet
}
}
}
public extension EdgeInsets {
init(all offset: CGFloat) {
self.init(top: offset, leading: offset, bottom: offset, trailing: offset)
}
init(horizontal: CGFloat, vertical: CGFloat) {
self.init(top: vertical, leading: horizontal, bottom: vertical, trailing: horizontal)
}
static var zero: Self { .init(all: 0) }
}
import SwiftUI
// MARK: - Pages
enum Screen: DestinationRepresentable {
case bedroom(name: String)
case livingRoom
}
enum Sheet: SheetRepresentable {
case error
}
// MARK: - Router
typealias MyRouter = Router<Screen, SheetType>
typealias SheetType = Sheet
struct RouterEnvironmentKey: EnvironmentKey {
@MainActor static let defaultValue: MyRouter = MyRouter()
}
extension EnvironmentValues {
var router: MyRouter {
get { self[RouterEnvironmentKey.self] }
set { self[RouterEnvironmentKey.self] = newValue }
}
}
// MARK: - ContentView
struct ContentView: View {
@Environment(\.router) var router
@State var isLoading: Bool = false
var body: some View {
NavHandling(loadingOverlayBinding: $isLoading) {
getRootView()
} destinationHandler: {
destination(for: $0)
} sheetHandler: {
modal(for: $0)
}
.padding(.horizontal)
.environmentObject(router)
.task {
isLoading = true
try? await Task.sleep(nanoseconds: 1_000_000_000)
isLoading = false
}
}
}
private extension ContentView {
@ViewBuilder
func getRootView() -> some View {
HomeView()
}
@ViewBuilder
func destination(for screen: Screen) -> some View {
switch screen {
case .bedroom(let name):
BedroomView(roomOwner: name)
case .livingRoom:
LivingroomView()
}
}
@ViewBuilder
func modal(for sheet: Sheet) -> some View {
switch sheet {
case .error:
ErrorView()
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment