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