From 724a66c74809a96696ddfb3f00a7410d47838a70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=A0i=20L=C3=AA?= Date: Thu, 21 Dec 2023 13:46:27 +0700 Subject: [PATCH] + Implement Navigation in SwiftUI with iOS 17+ and Navigation Stack. (#9) --- .../project.pbxproj | 8 ++ .../ViewNavigationTarget.swift | 107 ++++++++++++++---- .../NavigationStackExample/ContentView.swift | 12 +- .../Utils/View+Extension.swift | 3 + .../View/ListView.swift | 4 + .../View/PolicyView.swift | 6 + .../BaseNavigationStack.swift | 31 ++--- .../BaseNavigationView.swift | 42 +++++++ .../Interface/BaseViewProtocol.swift | 26 +++++ .../BaseNavigationStack+ViewObject.swift | 5 +- .../Utils/NavigationPath+Extension.swift | 12 -- 11 files changed, 185 insertions(+), 71 deletions(-) create mode 100644 Sources/BaseNavigationStack/BaseNavigationView.swift create mode 100644 Sources/BaseNavigationStack/Interface/BaseViewProtocol.swift delete mode 100644 Sources/BaseNavigationStack/Utils/NavigationPath+Extension.swift diff --git a/NavigationStackExample/NavigationStackExample.xcodeproj/project.pbxproj b/NavigationStackExample/NavigationStackExample.xcodeproj/project.pbxproj index b8ee5c7..6e2227b 100644 --- a/NavigationStackExample/NavigationStackExample.xcodeproj/project.pbxproj +++ b/NavigationStackExample/NavigationStackExample.xcodeproj/project.pbxproj @@ -85,6 +85,7 @@ 3E7A81DA2B2B0F4D0003D5A3 /* Utils */ = { isa = PBXGroup; children = ( + 3EEC9A792B301B540082BEE9 /* ViewModifer */, 3E7A81DB2B2B0FA90003D5A3 /* View+Extension.swift */, ); path = Utils; @@ -108,6 +109,13 @@ path = View; sourceTree = ""; }; + 3EEC9A792B301B540082BEE9 /* ViewModifer */ = { + isa = PBXGroup; + children = ( + ); + path = ViewModifer; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ diff --git a/NavigationStackExample/NavigationStackExample/BaseDefineTarget/ViewNavigationTarget.swift b/NavigationStackExample/NavigationStackExample/BaseDefineTarget/ViewNavigationTarget.swift index 07d5d57..a394cf7 100644 --- a/NavigationStackExample/NavigationStackExample/BaseDefineTarget/ViewNavigationTarget.swift +++ b/NavigationStackExample/NavigationStackExample/BaseDefineTarget/ViewNavigationTarget.swift @@ -6,24 +6,39 @@ // import Foundation +import BaseNavigationStack +import SwiftUI -public enum ViewNavigationTarget: Identifiable, Hashable { +public enum ViewNavigationTarget: BaseViewProtocol { + public func withNavigationDestination() -> some View { + switch self { + case .policy(_): + PolicyView() + case .list: + ListView() + case .splash: + SplashView() + default: + EmptyView() + } + } + public static func == (lhs: ViewNavigationTarget, rhs: ViewNavigationTarget) -> Bool { lhs.id == rhs.id } public var id: String { switch self { - case .policy(_): - return "policy" - case .updateProfile(_): - return "updateProfile" - case .splash: - return "splash" - case .main: - return "main" - case .list: - return "list" + case .policy(_): + return "policy" + case .updateProfile(_): + return "updateProfile" + case .splash: + return "splash" + case .main: + return "main" + case .list: + return "list" } } @@ -35,20 +50,62 @@ public enum ViewNavigationTarget: Identifiable, Hashable { public func hash(into hasher: inout Hasher) { switch self { - case .splash: - hasher.combine("splash") - case .main: - hasher.combine("main") - case .list: - hasher.combine("list") - case .policy(let url): - hasher.combine("policy") - hasher.combine(url) - case .updateProfile(let closure): - hasher.combine("updateProfile") - // Note: You may want to provide a more specific hash for closures - // depending on your use case. - hasher.combine(ObjectIdentifier(closure as AnyObject)) + case .splash: + hasher.combine("splash") + case .main: + hasher.combine("main") + case .list: + hasher.combine("list") + case .policy(let url): + hasher.combine("policy") + hasher.combine(url) + case .updateProfile(let closure): + hasher.combine("updateProfile") + hasher.combine(ObjectIdentifier(closure as AnyObject)) + } + } + + public func withSheetDestination() -> some View { + switch self { + case .policy(_): + PolicyView() + .presentationDetents(self.detent) + case .list: + ListView() + .presentationDetents(self.detent) + case .splash: + SplashView() + .presentationDetents(self.detent) + default: + EmptyView() + } + } + + public func withFullScreenDestination() -> some View { + switch self { + case .policy(_): + PolicyView() + case .list: + ListView() + case .splash: + SplashView() + default: + EmptyView() + } + } + + public var detent: Set { + switch self { + case .splash: + return [.large] + case .main: + return [.large] + case .list: + return [.large] + case .policy(_): + return [.large] + case .updateProfile(_): + return [.large] } } } diff --git a/NavigationStackExample/NavigationStackExample/ContentView.swift b/NavigationStackExample/NavigationStackExample/ContentView.swift index 8428f71..882aacc 100644 --- a/NavigationStackExample/NavigationStackExample/ContentView.swift +++ b/NavigationStackExample/NavigationStackExample/ContentView.swift @@ -10,19 +10,11 @@ import BaseNavigationStack import Foundation typealias BaseNavigation = BaseNavigationStack - struct ContentView: View { - @State - var navigationRouter = BaseNavigation(isPresented: .constant(.splash)) - var body: some View { - NavigationStack(path: navigationRouter.navigationPath) { - BaseView() - .withNavigationRouter() - .withSheetRouter(sheetDestination: navigationRouter.presentingSheet) - .withFullScreenRouter(navigationRouter.presentingFullScreen) + BaseNavigationView { + SplashView() } - .environment(navigationRouter) } } diff --git a/NavigationStackExample/NavigationStackExample/Utils/View+Extension.swift b/NavigationStackExample/NavigationStackExample/Utils/View+Extension.swift index e656bbf..b93fe2b 100644 --- a/NavigationStackExample/NavigationStackExample/Utils/View+Extension.swift +++ b/NavigationStackExample/NavigationStackExample/Utils/View+Extension.swift @@ -31,12 +31,15 @@ extension View { PolicyView() case .list: ListView() + case .splash: + SplashView() default: EmptyView() } } } + @MainActor func withFullScreenRouter(_ presentFullView: Binding) -> some View { fullScreenCover(item: presentFullView) { fullView in switch fullView { diff --git a/NavigationStackExample/NavigationStackExample/View/ListView.swift b/NavigationStackExample/NavigationStackExample/View/ListView.swift index b2b0989..9f3481d 100644 --- a/NavigationStackExample/NavigationStackExample/View/ListView.swift +++ b/NavigationStackExample/NavigationStackExample/View/ListView.swift @@ -20,6 +20,7 @@ struct ListView: View { HStack { Button { dimiss() + navigationRouter.dismiss() } label: { Text("Dis") } @@ -60,6 +61,7 @@ struct ListView: View { Spacer() } .navigationTitle("ListView") + .navigationBarTitleDisplayMode(.inline) } } @@ -77,6 +79,7 @@ struct SplashView: View { HStack { Button { dimiss() + navigationRouter.dismiss() } label: { Text("Dis") } @@ -105,6 +108,7 @@ struct SplashView: View { Spacer() } .navigationTitle("Splash") + .navigationBarTitleDisplayMode(.inline) } } diff --git a/NavigationStackExample/NavigationStackExample/View/PolicyView.swift b/NavigationStackExample/NavigationStackExample/View/PolicyView.swift index 1c878fc..8c84198 100644 --- a/NavigationStackExample/NavigationStackExample/View/PolicyView.swift +++ b/NavigationStackExample/NavigationStackExample/View/PolicyView.swift @@ -20,9 +20,15 @@ struct PolicyView: View { HStack { Button { dimiss() + navigationRouter.dismiss() } label: { Text("Dis") } + Button { + navigationRouter.presentSheet(.splash) + } label: { + Text("PresentSplas") + } } HStack { Button { diff --git a/Sources/BaseNavigationStack/BaseNavigationStack.swift b/Sources/BaseNavigationStack/BaseNavigationStack.swift index 49ea31a..9ccab66 100644 --- a/Sources/BaseNavigationStack/BaseNavigationStack.swift +++ b/Sources/BaseNavigationStack/BaseNavigationStack.swift @@ -7,11 +7,14 @@ import Observation @Observable /// Base Navigation Stack With Generic Type. -public class BaseNavigationStack where ScreenView: Hashable { +public class BaseNavigationStack where ScreenView: BaseViewProtocol { private(set) var state: RootingState public var urlHandler: ((URL) -> OpenURLAction.Result)? + public init(isPresented: Binding) { - state = RootingState(isPresented: isPresented) + state = RootingState( + isPresented: isPresented + ) } } @@ -25,7 +28,6 @@ public extension BaseNavigationStack { /// - Parameter viewSpec: Push View @MainActor func pushToView(_ viewSpec: ScreenView) { - // if state.isPresenting { dismiss() } state.navigationPath.append(viewSpec) } @@ -40,7 +42,6 @@ public extension BaseNavigationStack { func navigateToRoot() { if state.isPresenting { state.presentingFullScreen = nil - state.presentingModal = nil state.presentingSheet = nil } state.navigationPath.removeAllSafe() @@ -57,7 +58,6 @@ public extension BaseNavigationStack { /// - Parameter viewSpec: present Sheet View @MainActor func presentSheet(_ viewSpec: ScreenView) { - // if state.isPresenting { dismiss() } state.presentingSheet = viewSpec } @@ -65,17 +65,9 @@ public extension BaseNavigationStack { /// - Parameter viewSpec: PushViewTarget @MainActor func presentFullScreen(_ viewSpec: ScreenView) { - // if state.isPresenting { dismiss() } state.presentingFullScreen = viewSpec } - - /// Present Modal View - /// - Parameter viewSpec: PushViewTarget - @MainActor - func presentModal(_ viewSpec: ScreenView) { - state.presentingModal = viewSpec - } - + /// Dismiss If View Is Presenting or Embed In Navigation Stack @MainActor func dismiss() { @@ -83,14 +75,17 @@ public extension BaseNavigationStack { state.presentingSheet = nil } else if state.presentingFullScreen != nil { state.presentingFullScreen = nil - } else if state.presentingModal != nil { - state.presentingModal = nil } else if navigationPath.count > 1 { state.navigationPath.removeLast() } else { state.isPresented.wrappedValue = nil } } + + public func handleOpenURL(url: URL) -> OpenURLAction.Result { + // TODO: Update URL Handler + return urlHandler?(url) ?? .systemAction + } } public extension BaseNavigationStack { @@ -106,10 +101,6 @@ public extension BaseNavigationStack { binding(keyPath: \.presentingFullScreen) } - var presentingModal: Binding { - binding(keyPath: \.presentingModal) - } - var isPresented: Binding { state.isPresented } diff --git a/Sources/BaseNavigationStack/BaseNavigationView.swift b/Sources/BaseNavigationStack/BaseNavigationView.swift new file mode 100644 index 0000000..c17728c --- /dev/null +++ b/Sources/BaseNavigationStack/BaseNavigationView.swift @@ -0,0 +1,42 @@ +// +// BaseNavigationView.swift +// +// +// Created by iletai on 20/12/2023. +// + +import Foundation +import SwiftUI +import Combine + +public struct BaseNavigationView: View where ScreenView: BaseViewProtocol { + public typealias BaseNavigation = BaseNavigationStack + /// Base Navigation Stack Root View + public let rootView: RootView + + @State + private var navigationRouter = BaseNavigation(isPresented: .constant(nil)) + + public init(@ViewBuilder rootView: @escaping () -> RootView) { + self.rootView = rootView() + } + + public var body: some View { + NavigationStack(path: navigationRouter.navigationPath) { + rootView + .navigationDestination(for: ScreenView.self) { screen in + screen.withNavigationDestination() + } + .sheet(item: navigationRouter.presentingSheet) { item in + item.withSheetDestination() + } + .fullScreenCover(item: navigationRouter.presentingFullScreen, content: { fullScreen in + fullScreen.withFullScreenDestination() + }) + .environment(\.openURL, OpenURLAction { + navigationRouter.handleOpenURL(url: $0) + }) + } + .environment(navigationRouter) + } +} diff --git a/Sources/BaseNavigationStack/Interface/BaseViewProtocol.swift b/Sources/BaseNavigationStack/Interface/BaseViewProtocol.swift new file mode 100644 index 0000000..0108118 --- /dev/null +++ b/Sources/BaseNavigationStack/Interface/BaseViewProtocol.swift @@ -0,0 +1,26 @@ +// +// PresentViewProtocol.swift +// +// +// Created by iletai on 18/12/2023. +// + +import Foundation +import SwiftUI + +public protocol BaseViewProtocol: Identifiable, Hashable { + associatedtype PresentSheetScreenView: View + associatedtype DestinationView: View + associatedtype PresentFullScreenView: View + + @MainActor + @ViewBuilder + func withSheetDestination() -> PresentSheetScreenView + + @ViewBuilder + func withFullScreenDestination() -> PresentFullScreenView + + @MainActor + @ViewBuilder + func withNavigationDestination() -> DestinationView +} diff --git a/Sources/BaseNavigationStack/StackObject/BaseNavigationStack+ViewObject.swift b/Sources/BaseNavigationStack/StackObject/BaseNavigationStack+ViewObject.swift index 2544df0..e8d7f7b 100644 --- a/Sources/BaseNavigationStack/StackObject/BaseNavigationStack+ViewObject.swift +++ b/Sources/BaseNavigationStack/StackObject/BaseNavigationStack+ViewObject.swift @@ -20,14 +20,11 @@ extension BaseNavigationStack { /// View Is Presenting Full var presentingFullScreen: ScreenView? = nil - /// View Is Presenting Modal - var presentingModal: ScreenView? = nil - /// Status Is Presenting A View var isPresented: Binding var isPresenting: Bool { - return presentingSheet != nil || presentingFullScreen != nil || presentingModal != nil + return presentingSheet != nil || presentingFullScreen != nil } /// Initial diff --git a/Sources/BaseNavigationStack/Utils/NavigationPath+Extension.swift b/Sources/BaseNavigationStack/Utils/NavigationPath+Extension.swift deleted file mode 100644 index 6cbed1b..0000000 --- a/Sources/BaseNavigationStack/Utils/NavigationPath+Extension.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// NavigationPath+Extension.swift -// -// -// Created by Lê Quang Trọng Tài on 12/17/23. -// - -import SwiftUI - -extension NavigationPath { - -}