├── .github └── FUNDING.yml ├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE ├── Package.swift ├── README.md ├── READMECN.md ├── Sources └── NavigationViewKit │ ├── DoubleColoumnJustForPadNavigationViewStyle.swift │ ├── FixDoubleColumnNavigationViewStyle.swift │ ├── NavigationViewManager.swift │ ├── NavigationViewManagerMisc.swift │ └── TipOnceDoubleColumnNavigationViewStyle.swift └── Tests └── NavigationViewKitTests └── NavigationViewKitTests.swift /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | custom: [https://www.buymeacoffee.com/fatbobman, https://www.fatbobman.com/support/] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | Package.resolved 9 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Yang Xu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "NavigationViewKit", 8 | platforms: [ 9 | .iOS(.v14), 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, and make them visible to other packages. 13 | .library( 14 | name: "NavigationViewKit", 15 | targets: ["NavigationViewKit"]), 16 | ], 17 | dependencies: [ 18 | // Dependencies declare other packages that this package depends on. 19 | // .package(url: /* package url */, from: "1.0.0"), 20 | .package(name: "Introspect", url: "https://github.com/siteline/SwiftUI-Introspect.git", from: "0.0.1") 21 | ], 22 | targets: [ 23 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 24 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 25 | .target( 26 | name: "NavigationViewKit", 27 | dependencies: ["Introspect"]), 28 | .testTarget( 29 | name: "NavigationViewKitTests", 30 | dependencies: ["NavigationViewKit"]), 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NavigationViewKit # 2 | 3 | [中文版说明](READMECN.md) 4 | 5 | NavigationViewKit is a NavigationView extension library for SwiftUI. 6 | 7 | For more detailed documentation and demo, please visit [用NavigationViewKit增强SwiftUI的导航视图](https://www.fatbobman.com/posts/NavigationViewKit/) 8 | 9 | The extension follows several principles. 10 | 11 | * Non-disruptive 12 | 13 | Any new feature does not affect the native functionality provided by Swiftui, especially if it does not affect the performance of Toolbar, NavigationLink in NavigationView 14 | 15 | * Be as easy to use as possible 16 | 17 | Add new features with minimal code 18 | 19 | * SwiftUI native style 20 | 21 | Extensions should be called in the same way as the native SwiftUI as much as possible 22 | 23 | 24 | ## NavigationViewManager ## 25 | 26 | ### Introduction ### 27 | 28 | One of the biggest complaints from developers about NavigationView is that it does not support a convenient means of returning to the root view. There are two commonly used solutions. 29 | 30 | * Repackage the `UINavigationController` 31 | 32 | A good wrapper can indeed use the many functions provided by `UINavigationController`, but it is very easy to conflict with the native methods in SwiftUI, so you can't have both. 33 | 34 | * Use procedural `NavigationLink 35 | 36 | Return the programmed `NavigationLink` (usually `isActive`) by undoing the root view. This means will limit the variety of `NavigationLink` options, plus is not conducive to implementation from non-view code. 37 | 38 | `NavigationViewManager` is the navigation view manager provided in NavigationViewKit, it provides the following functions. 39 | 40 | * Can manage all the NavigationView in the application 41 | * Support to return the root view directly from any view under NavigationView by code 42 | * Jump to a new view via code from any view under NavigationView (no need to describe `NavigationLink` in the view) 43 | * Return to the root view via `NotificatiionCenter` for any NavigationView in the specified application 44 | * By `NotificatiionCenter`, let any NavigationView in the application jump to the new view 45 | * Support transition animation on and off 46 | 47 | ### Register NavigationView ### 48 | 49 | Since `NavigationgViewManager` supports multiple navigation views management, you need to register for each managed navigation view. 50 | 51 | 52 | ```swift 53 | import NavigationViewKit 54 | NavigationView { 55 | List(0..<10) { _ in 56 | NavigationLink("abc", destination: DetailView()) 57 | } 58 | } 59 | .navigationViewManager(for: "nv1", afterBackDo: {print("back to root") }) 60 | ``` 61 | 62 | `navigationViewManager` is a View extension, defined as follows. 63 | 64 | ```swift 65 | extension View { 66 | public func navigationViewManager(for tag: String, afterBackDo cleanAction: @escaping () -> Void = {}) -> some View 67 | } 68 | ``` 69 | 70 | `for` is the name (or tag) of the currently registered `NavigationView`, `afterBackDo` is the code segment executed when going to the root view. 71 | 72 | The tag of each managed `NavigationView` in the application needs to be unique. 73 | 74 | ### Returning to the root view from a view ### 75 | 76 | In any sub-view of a registered `NavigationView`, the return to the root view can be achieved with the following code. 77 | 78 | ```swift 79 | @Environment(\.navigationManager) var nvmanager 80 | 81 | Button("back to root view") { 82 | nvmanager.wrappedValue.popToRoot(tag:"nv1"){ 83 | print("other back") 84 | } 85 | } 86 | ``` 87 | 88 | `popToRoot` is defined as follows. 89 | 90 | ```swift 91 | func popToRoot(tag: String, animated: Bool = true, action: @escaping () -> Void = {}) 92 | ``` 93 | 94 | `tag` is the registered Tag of the current NavigationView, `animated` sets whether to show the transition animation when returning to the root view, and `action` is the further after-back code segment. This code will be executed after the registration code segment (`afterBackDo`) and is mainly used to pass the data in the current view. 95 | 96 | This can be done via the 97 | 98 | ```swift 99 | @Environment(\.currentNaviationViewName) var tag 100 | ``` 101 | 102 | Get the registered Tag of the current NavigationView, so that the view can be reused in different NavigtionViews. 103 | 104 | ```swift 105 | struct DetailView: View { 106 | @Environment(\.navigationManager) var nvmanager 107 | @Environment(\.currentNaviationViewName) var tag 108 | var body: some View { 109 | VStack { 110 | Button("back to root view") { 111 | if let tag = tag { 112 | nvmanager.wrappedValue.popToRoot(tag:tag,animated: false) { 113 | print("other back") 114 | } 115 | } 116 | } 117 | } 118 | } 119 | } 120 | ``` 121 | 122 | ### Using NotificationCenter to return to the root view ### 123 | 124 | Since the main use of NavigationViewManager in my app is to handle `Deep Link`, the vast majority of the time it is not called in the view code. So NavigationViewManager provides a similar method based on `NotificationCenter`. 125 | 126 | In the code using : 127 | 128 | ```swift 129 | let backToRootItem = NavigationViewManager.BackToRootItem(tag: "nv1", animated: false, action: {}) 130 | NotificationCenter.default.post(name: .NavigationViewManagerBackToRoot, object: backToRootItem) 131 | ``` 132 | 133 | 134 | Returns the specified NavigationView to the root view. 135 | 136 | 137 | ### Jump from a view to a new view ### 138 | 139 | Use : 140 | 141 | 142 | ```swift 143 | @Environment(\.navigationManager) var nvmanager 144 | 145 | Button("go to new View"){ 146 | nvmanager.wrappedValue.pushView(tag:"nv1",animated: true){ 147 | Text("New View") 148 | .navigationTitle("new view") 149 | } 150 | } 151 | ``` 152 | 153 | The definition of `pushView` is as follows. 154 | 155 | ```swift 156 | func pushView(tag: String, animated: Bool = true, @ViewBuilder view: () -> V) 157 | ``` 158 | 159 | `tag` is the registered Tag of NavigationView, `animation` sets whether to show the transition animation, `view` is the new view. The view supports all definitions native to SwiftUI, such as `toolbar`, `navigationTitle`, etc. 160 | 161 | At the moment, when transition animation is enabled, title and toolbar will be shown after the transition animation, so the view is a little bit short. I will try to fix it in the future. 162 | 163 | ### Use NotificationCenter to jump to new view ### 164 | 165 | In the code. 166 | 167 | ```swift 168 | let pushViewItem = NavigationViewManager.PushViewItem(tag: "nv1", animated: false) { 169 | AnyView( 170 | Text("New View") 171 | .navigationTitle("第四级视图") 172 | ) 173 | } 174 | NotificationCenter.default.post(name:.NavigationViewManagerPushView, object: pushViewItem) 175 | ``` 176 | 177 | `tag` is the registered Tag of NavigationView, `animation` sets whether to show the transition animation, `view` is the new view. The view supports all definitions native to SwiftUI, such as `toolbar`, `navigationTitle`, etc. 178 | 179 | At the moment, when transition animation is enabled, title and toolbar will be shown after the transition animation, so the view is a little bit short. I will try to fix it in the future. 180 | 181 | ### Use NotificationCenter to jump to new view ### 182 | 183 | In the code. 184 | 185 | 186 | ## DoubleColoumnJustForPadNavigationViewStyle ## 187 | 188 | `DoubleColoumnJustForPadNavigationViewStyle` is a modified version of `DoubleColoumnNavigationViewStyle`, its purpose is to improve the performance of `DoubleColoumnNavigationViewStyle` in landscape on iPhone Max when iPhone and iPad use the same set of code, and different from other iPhone models. 189 | 190 | When iPhone Max is in landscape, the NavigationView will behave like iPad with double columns, which makes the application behave inconsistently on different iPhones. 191 | 192 | When using `DoubleColoumnJustForPadNavigationViewStyle`, iPhone Max will still show `StackNavigationViewStyle` in landscape. 193 | 194 | Usage. 195 | 196 | ```swift 197 | NavigationView{ 198 | ... 199 | } 200 | .navigationViewStyle(DoubleColoumnJustForPadNavigationViewStyle()) 201 | ``` 202 | 203 | It can be used directly under swift 5.5 204 | 205 | ```swift 206 | .navigationViewStyle(.columnsForPad) 207 | ``` 208 | 209 | ## TipOnceDoubleColumnNavigationViewStyle ## 210 | 211 | The current `DoubleColumnNavigationViewStyle` behaves differently on iPad in both horizontal and vertical screens. When the screen is vertical, the left column is hidden by default, making it easy for new users to get confused. 212 | 213 | `TipOnceDoubleColumnNavigationViewStyle` will show the left column above the right column to remind the user when the iPad is in vertical screen for the first time. This reminder will only happen once. If you rotate the orientation after the reminder, the reminder will not be triggered again when you enter the vertical screen again. 214 | 215 | 216 | ```swift 217 | NavigationView{ 218 | ... 219 | } 220 | .navigationViewStyle(TipOnceDoubleColumnNavigationViewStyle()) 221 | ``` 222 | 223 | It can be used directly under swift 5.5 224 | 225 | ```swift 226 | .navigationViewStyle(.tipColumns) 227 | ``` 228 | 229 | 230 | ## FixDoubleColumnNavigationViewStyle ## 231 | 232 | In [Health Notes](https://www.fatbobman.com/healthnotes/), I want the iPad version to always keep two columns displayed no matter in landscape or portrait, and the left column cannot be hidden. 233 | 234 | I previously used HStack set of two NavigationView to achieve this effect 235 | 236 | Now, the above effect can be easily achieved by `FixDoubleColumnNavigationViewStyle` in NavigationViewKit directly. 237 | 238 | ```swift 239 | NavigationView{ 240 | ... 241 | } 242 | .navigationViewStyle(FixDoubleColumnNavigationViewStyle(widthForLandscape: 350, widthForPortrait:250)) 243 | ``` 244 | 245 | And you can set the left column width separately for both landscape and portrait states. 246 | 247 | For more detailed documentation and demo, please visit [My Blog](https://www.fatbobman.com/) 248 | 249 | -------------------------------------------------------------------------------- /READMECN.md: -------------------------------------------------------------------------------- 1 | # NavigationViewKit # 2 | 3 | NavigationViewKit是一个SwiftUI的NavigationView扩展库。 4 | 5 | 更详细的文档和演示,请访问[用NavigationViewKit增强SwiftUI的导航视图](https://www.fatbobman.com/posts/NavigationViewKit/) 6 | 7 | 该扩展遵循以下几个原则: 8 | 9 | * 非破坏性 10 | 11 | 任何新添加的功能都不能影响当前SwiftUI提供的原生功能,尤其是不能影响例如`Toolbar`、`NavigationLink`在NavigationView中的表现 12 | 13 | * 尽可能便于使用 14 | 15 | 仅需极少的代码便可使用新增功能 16 | 17 | * SwiftUI原生风格 18 | 19 | 扩展功能的调用方法尽可能同原生SwiftUI方式类似 20 | 21 | 22 | 23 | ## NavigationViewManager ## 24 | 25 | ### 简介 ### 26 | 27 | 开发者对NavigationView最大抱怨之一就是不支持便捷的返回根视图手段。目前常用的解决方案有两种: 28 | 29 | * 重新包装`UINavigationController` 30 | 31 | 好的包装确实可以使用到`UINavigationController`提供的众多功能,不过非常容易同SwiftUI中的原生方法相冲突,鱼和熊掌不可兼得 32 | 33 | * 使用程序化的`NavigationLink` 34 | 35 | 通过撤销根视图的程序化的`NavigationLink`(通常是`isActive`)来返回。此种手段将限制`NavigationLink`的种类选择,另外不利于从非视图代码中实现。 36 | 37 | `NavigationViewManager`是NavigationViewKit中提供的导航视图管理器,它提供如下功能: 38 | 39 | * 可以管理应用程序中全部的NavigationView 40 | * 支持从NavigationView下的任意视图通过代码直接返回根视图 41 | * 在NavigationView下的任意视图中通过代码直接跳转到新视图(无需在视图中描述`NavigationLink`) 42 | * 通过`NotificatiionCenter`,指定应用程序中的任意NavigationView返回根视图 43 | * 通过`NotificatiionCenter`,让应用程序中任意的NavigationView跳转到新视图 44 | * 支持转场动画的开启关闭 45 | 46 | ### 注册NavigationView ### 47 | 48 | 由于`NavigationgViewManager`支持多导航视图管理,因此需要为每个受管理的导航视图进行注册。 49 | 50 | ```swift 51 | import NavigationViewKit 52 | NavigationView { 53 | List(0..<10) { _ in 54 | NavigationLink("abc", destination: DetailView()) 55 | } 56 | } 57 | .navigationViewManager(for: "nv1", afterBackDo: {print("back to root") }) 58 | ``` 59 | 60 | `navigationViewManager`是一个View扩展,定义如下: 61 | 62 | ```swift 63 | extension View { 64 | public func navigationViewManager(for tag: String, afterBackDo cleanAction: @escaping () -> Void = {}) -> some View 65 | } 66 | ``` 67 | 68 | `for`为当前注册的`NavigationView`的名称(或tag),`afterBackDo`为当转到根视图后执行的代码段。 69 | 70 | 应用程序中每个被管理的`NavigationView`的tag需唯一。 71 | 72 | ### 从视图中返回根视图 ### 73 | 74 | 在注册过的`NavigationView`的任意子视图中,可以通过下面的代码实现返回根视图: 75 | 76 | ```swift 77 | @Environment(\.navigationManager) var nvmanager 78 | 79 | Button("back to root view") { 80 | nvmanager.wrappedValue.popToRoot(tag:"nv1"){ 81 | print("other back") 82 | } 83 | } 84 | ``` 85 | 86 | `popToRoot`定义如下: 87 | 88 | ```swift 89 | func popToRoot(tag: String, animated: Bool = true, action: @escaping () -> Void = {}) 90 | ``` 91 | 92 | `tag`为当前NavigationView的注册Tag,`animated`设置返回根视图时是否显示转场动画,`action`为进一步的善后代码段。该段代码将执行在注册代码段(`afterBackDo`)之后,主要用于传递当前视图中的数据。 93 | 94 | 可以通过 95 | 96 | ```swift 97 | @Environment(\.currentNaviationViewName) var tag 98 | ``` 99 | 100 | 获取到当前NavigationView的注册Tag,便于视图在不同的NavigtionView中复用 101 | 102 | ```swift 103 | struct DetailView: View { 104 | @Environment(\.navigationManager) var nvmanager 105 | @Environment(\.currentNaviationViewName) var tag 106 | var body: some View { 107 | VStack { 108 | Button("back to root view") { 109 | if let tag = tag { 110 | nvmanager.wrappedValue.popToRoot(tag:tag,animated: false) { 111 | print("other back") 112 | } 113 | } 114 | } 115 | } 116 | } 117 | } 118 | ``` 119 | 120 | ### 使用NotificationCenter返回根视图 ### 121 | 122 | 由于NavigationViewManager在我的app中主要的用途是处理`Deep Link`,绝大多数的时间都不是在视图代码中调用的。因此NavigationViewManager提供了基于`NotificationCenter`的类似方法。 123 | 124 | 在代码中使用: 125 | 126 | ```swift 127 | let backToRootItem = NavigationViewManager.BackToRootItem(tag: "nv1", animated: false, action: {}) 128 | NotificationCenter.default.post(name: .NavigationViewManagerBackToRoot, object: backToRootItem) 129 | ``` 130 | 131 | 让指定的NavigationView返回到根视图。 132 | 133 | 134 | ### 从视图中跳转到新视图 ### 135 | 136 | 在视图代码中使用: 137 | 138 | ```swift 139 | @Environment(\.navigationManager) var nvmanager 140 | 141 | Button("go to new View"){ 142 | nvmanager.wrappedValue.pushView(tag:"nv1",animated: true){ 143 | Text("New View") 144 | .navigationTitle("new view") 145 | } 146 | } 147 | ``` 148 | 149 | `pushView`的定义如下: 150 | 151 | ```swift 152 | func pushView(tag: String, animated: Bool = true, @ViewBuilder view: () -> V) 153 | ``` 154 | 155 | `tag`为NavigationView的注册Tag,`animation`设置是否显示转场动画,`view`为新视图。视图中支持SwiftUI原生的所有定义,例如`toolbar`、`navigationTitle`等。 156 | 157 | 目前在启用转场动画时,title和toolbar会在转场动画后才显示,观感稍有不足。日后尝试解决。 158 | 159 | ### 使用NotificationCenter跳转到新视图 ### 160 | 161 | 在代码中: 162 | 163 | ```swift 164 | let pushViewItem = NavigationViewManager.PushViewItem(tag: "nv1", animated: false) { 165 | AnyView( 166 | Text("New View") 167 | .navigationTitle("第四级视图") 168 | ) 169 | } 170 | NotificationCenter.default.post(name:.NavigationViewManagerPushView, object: pushViewItem) 171 | ``` 172 | 173 | 通过NotificationCenter跳转视图时,视图需转换为`AnyView`。 174 | 175 | 176 | ## DoubleColoumnJustForPadNavigationViewStyle ## 177 | 178 | `DoubleColoumnJustForPadNavigationViewStyle`是`DoubleColoumnNavigationViewStyle`的修改版,其目改善iPhone和iPad使用同一套代码时,`DoubleColoumnNavigationViewStyle`在iPhone Max上横屏时的表现同其他iPhone机型不同。 179 | 180 | 当iPhone Max横屏时,NavigationView的表现会同iPad一样双列显示,让应用程序在不同iPhone上的表现不一致。 181 | 182 | 使用`DoubleColoumnJustForPadNavigationViewStyle`时,iPhone Max在横屏时仍呈现`StackNavigationViewStyle`的式样。 183 | 184 | 使用方法: 185 | 186 | ```swift 187 | NavigationView{ 188 | ... 189 | } 190 | .navigationViewStyle(DoubleColoumnJustForPadNavigationViewStyle()) 191 | ``` 192 | 193 | 在swift 5.5下可以直接使用 194 | 195 | ```swift 196 | .navigationViewStyle(.columnsForPad) 197 | ``` 198 | 199 | ## TipOnceDoubleColumnNavigationViewStyle ## 200 | 201 | 当前`DoubleColumnNavigationViewStyle`在iPad上横竖屏的表现不同。当竖屏时,左侧栏默认会隐藏,容易让新用户无所适从。 202 | 203 | `TipOnceDoubleColumnNavigationViewStyle`会在iPad首次进入竖屏状态时,将左侧栏显示在右侧栏上方,提醒使用者。该提醒只会进行一次。提醒后旋转了方向,再次进入竖屏状态则不会二次触发提醒。 204 | 205 | ```swift 206 | NavigationView{ 207 | ... 208 | } 209 | .navigationViewStyle(TipOnceDoubleColumnNavigationViewStyle()) 210 | ``` 211 | 212 | 在Swift 5.5下可以直接使用 213 | 214 | ```swift 215 | .navigationViewStyle(.tipColumns) 216 | ``` 217 | 218 | 219 | ## FixDoubleColumnNavigationViewStyle ## 220 | 221 | 在[健康笔记](https://www.fatbobman.com/healthnotes/)中,我希望iPad版本无论在横屏或竖屏时,都始终能够保持两栏显示的状态,且左侧栏不可隐藏。 222 | 223 | 我之前使用了HStack套两个NavigationView来达到这个效果 224 | 225 | 现在,可以直接NavigationViewKit中的`FixDoubleColumnNavigationViewStyle`轻松实现上述效果。 226 | 227 | ```swift 228 | NavigationView{ 229 | ... 230 | } 231 | .navigationViewStyle(FixDoubleColumnNavigationViewStyle(widthForLandscape: 350, widthForPortrait:250)) 232 | ``` 233 | 234 | 并且可以为横屏竖屏两种状态分别设置左侧栏宽度。 235 | 236 | 更详细的文档和演示,请访问[用NavigationViewKit增强SwiftUI的导航视图](https://www.fatbobman.com/posts/NavigationViewKit/) 237 | 238 | 239 | -------------------------------------------------------------------------------- /Sources/NavigationViewKit/DoubleColoumnJustForPadNavigationViewStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DoubleColoumnJustForPadNavigationViewStyle.swift 3 | // 4 | // 5 | // Created by Yang Xu on 2021/8/31. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | public struct DeviceKey: EnvironmentKey { 12 | public static var defaultValue = UIDevice.current.userInterfaceIdiom 13 | } 14 | 15 | public extension EnvironmentValues { 16 | var device: UIUserInterfaceIdiom { self[DeviceKey.self] } 17 | } 18 | 19 | /// 屏蔽掉iPhoneMax在横屏状态下的双列显示。只在iPad上支持双列显示 20 | public struct DoubleColoumnJustForPadNavigationViewStyle: NavigationViewStyle { 21 | @Environment(\.device) var device 22 | public init() {} 23 | 24 | public func _body(configuration: _NavigationViewStyleConfiguration) -> some View { 25 | if device == .pad { 26 | NavigationView { 27 | configuration.content 28 | } 29 | .navigationViewStyle(DoubleColumnNavigationViewStyle()) 30 | } else { 31 | NavigationView { 32 | configuration.content 33 | } 34 | .navigationViewStyle(StackNavigationViewStyle()) 35 | } 36 | } 37 | 38 | public func _columnBasedBody(configuration: _NavigationViewStyleConfiguration) -> EmptyView { 39 | EmptyView() 40 | } 41 | } 42 | 43 | public extension NavigationViewStyle where Self == DoubleColoumnJustForPadNavigationViewStyle { 44 | static var columnsForPad: DoubleColoumnJustForPadNavigationViewStyle { DoubleColoumnJustForPadNavigationViewStyle() } 45 | } 46 | 47 | public extension View { 48 | func doubleColoumnJustForPadNavigationView() -> some View { 49 | modifier(DoubleColoumnJustForPadNavigationViewModifier()) 50 | } 51 | } 52 | 53 | struct DoubleColoumnJustForPadNavigationViewModifier: ViewModifier { 54 | @Environment(\.device) var device 55 | public func body(content: Content) -> some View { 56 | if device == .pad { 57 | content 58 | .navigationViewStyle(DoubleColumnNavigationViewStyle()) 59 | } else { 60 | content 61 | .navigationViewStyle(StackNavigationViewStyle()) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/NavigationViewKit/FixDoubleColumnNavigationViewStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FixDoubleColumnNavigationViewStyle.swift 3 | // 4 | // 5 | // Created by Yang Xu on 2021/8/31. 6 | // 7 | 8 | import Combine 9 | import Foundation 10 | import Introspect 11 | import SwiftUI 12 | 13 | /// 一个让iPad设备在何种状态下均保持双列的NavigationViewStyle 14 | /// 可以分别设置横向或竖向显示时主栏的宽度 15 | /// ```swift 16 | /// NavigationView{ 17 | /// ... 18 | /// } 19 | /// .navigationViewStyle(FixDoubleColumnNavigationViewStyle(widthForLandscape: 350, widthForPortrait:250)) 20 | /// ``` 21 | public struct FixDoubleColumnNavigationViewStyle: NavigationViewStyle { 22 | let widthForLandscape: CGFloat 23 | let widthForPortrait: CGFloat 24 | @StateObject var orientation = DeviceOrientation() 25 | public init(widthForLandscape: CGFloat = 350, widthForPortrait: CGFloat = 350) { 26 | self.widthForLandscape = widthForLandscape 27 | self.widthForPortrait = widthForPortrait 28 | } 29 | 30 | // iOS 15新添加的方法,用不到,直接返回空视图 31 | public func _columnBasedBody(configuration: _NavigationViewStyleConfiguration) -> EmptyView { 32 | EmptyView() 33 | } 34 | 35 | public func _body(configuration: _NavigationViewStyleConfiguration) -> some View { 36 | NavigationView { 37 | configuration.content 38 | } 39 | .navigationViewStyle(DoubleColumnNavigationViewStyle()) 40 | .introspectNavigationController { nv in 41 | nv.splitViewController?.preferredDisplayMode = .oneBesideSecondary 42 | nv.splitViewController?.presentsWithGesture = false // 这是关键 43 | withAnimation { 44 | if orientation.orientation == .landscape { 45 | withAnimation { 46 | nv.splitViewController?.maximumPrimaryColumnWidth = widthForLandscape 47 | nv.splitViewController?.preferredPrimaryColumnWidth = widthForLandscape 48 | } 49 | } else { 50 | nv.splitViewController?.maximumPrimaryColumnWidth = widthForPortrait 51 | nv.splitViewController?.preferredPrimaryColumnWidth = widthForPortrait 52 | } 53 | } 54 | } 55 | } 56 | } 57 | 58 | /// 监控设备旋转 59 | final class DeviceOrientation: ObservableObject { 60 | enum Orientation { 61 | case portrait 62 | case landscape 63 | } 64 | 65 | @Published var orientation: Orientation 66 | private var listener: AnyCancellable? 67 | init() { 68 | orientation = UIDevice.current.orientation.isLandscape ? .landscape : .portrait 69 | listener = NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification) 70 | .compactMap { ($0.object as? UIDevice)?.orientation } 71 | .compactMap { deviceOrientation -> Orientation? in 72 | if deviceOrientation.isPortrait { 73 | return .portrait 74 | } else if deviceOrientation.isLandscape { 75 | return .landscape 76 | } else { 77 | return nil 78 | } 79 | } 80 | .assign(to: \.orientation, on: self) 81 | } 82 | 83 | deinit { 84 | listener?.cancel() 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Sources/NavigationViewKit/NavigationViewManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NavigationViewManager.swift 3 | // NavigationViewManager 4 | // 5 | // Created by Yang Xu on 2021/8/31. 6 | // www.fatbobman.com 7 | // 8 | 9 | import Combine 10 | import SwiftUI 11 | 12 | /// NavigationView管理器,支持直接返回根视图和直接push新视图 13 | /// 在视图中使用NavigationViewManager范例: 14 | /// ```swift 15 | /// NavigationView{ 16 | /// ... 17 | /// } 18 | /// .navigationViewManager(for: "nv1", afterBackDo: {print("back to root") }) 19 | /// 20 | /// //在任意视图中使用 21 | /// @Environment(\.navigationManager) var nvmanager 22 | /// // 返回根视图 23 | /// nvmanager.wrappedValue.popToRoot(tag: "nv1", animated: true, action: { print("back from view") }) 24 | /// // 添加新视图 25 | /// nvmanager.wrappedValue.pushView(tag: "test2"){ 26 | /// Text("abc") 27 | /// .navigationTitle("hello world") 28 | /// .toolbar{ 29 | /// ToolbarItem{ 30 | /// Button("test2"){} 31 | /// } 32 | /// } 33 | /// } 34 | /// ``` 35 | public class NavigationViewManager { 36 | var contorllers: [String: ControllerItem] = [:] 37 | var cancllables: Set = [] 38 | 39 | public init() { 40 | NotificationCenter.default.publisher(for: .NavigationViewManagerBackToRoot, object: nil) 41 | .sink(receiveValue: { notification in 42 | self.backToRootObsever(notification: notification) 43 | }) 44 | .store(in: &cancllables) 45 | 46 | NotificationCenter.default.publisher(for: .NavigationViewManagerPushView, object: nil) 47 | .sink(receiveValue: { notification in 48 | self.pushViewObsever(notification: notification) 49 | }) 50 | .store(in: &cancllables) 51 | } 52 | 53 | /// 注册UINavigationController 54 | /// - Parameters: 55 | /// - controller: UINavigationController 56 | /// - tag: NavigationView的名字(自定义)。用于分辨同一app中的多个NavigationView 57 | /// - cleanAction: 返回根目录后执行的代码段 58 | func addController(controller: UINavigationController, tag: String, cleanAction: @escaping () -> Void = {}) { 59 | contorllers[tag] = ControllerItem(controller: controller, cleanAction: cleanAction) 60 | print(contorllers) 61 | } 62 | 63 | /// 返回根视图 64 | /// - Parameters: 65 | /// - tag: NavigationView的名字(自定义)。用于分辨同一app中的多个NavigationView 66 | /// - animated: 是否在返回时使用动画 67 | /// - action: 返回后执行的代码段。该代码段的执行顺序在cleanAction之后 68 | public func popToRoot(tag: String, animated: Bool = true, action: @escaping () -> Void = {}) { 69 | contorllers[tag]?.controller.popToRootViewController(animated: animated) 70 | contorllers[tag]?.cleanAction() 71 | action() 72 | } 73 | 74 | /// 直接添加一个新视图到指定的NavigationView中 75 | /// - Parameters: 76 | /// - tag: NavigationView的名字(自定义)。用于分辨同一app中的多个NavigationView 77 | /// - animated: 转换至新视图是否使用动画 78 | /// - view: 视图内容 79 | public func pushView(tag: String, animated: Bool = true, @ViewBuilder view: () -> V) { 80 | guard let controllerItem = contorllers[tag] else { return } 81 | controllerItem.controller.pushViewController(UIHostingController(rootView: view()), animated: animated) 82 | } 83 | 84 | /// 删除已注册的NavigationView,除非在app中NavigationView会很多且动态出现,否则无需使用 85 | /// - Parameter tag: NavigationView的名字(自定义)。用于分辨同一app中的多个NavigationView 86 | public func delController(tag: String) { 87 | contorllers[tag] = nil 88 | } 89 | 90 | private func backToRootObsever(notification: Notification) { 91 | if let backToRootItem = notification.object as? BackToRootItem { 92 | popToRoot(tag: backToRootItem.tag, animated: backToRootItem.animated, action: backToRootItem.action) 93 | } 94 | } 95 | 96 | private func pushViewObsever(notification: Notification) { 97 | guard let pushViewItem = notification.object as? PushViewItem else { return } 98 | pushView(tag: pushViewItem.tag, animated: pushViewItem.animated, view: pushViewItem.view) 99 | } 100 | 101 | deinit { 102 | NotificationCenter.default.removeObserver(self) 103 | } 104 | 105 | public struct ControllerItem { 106 | let controller: UINavigationController 107 | var cleanAction: () -> Void 108 | } 109 | 110 | /// 使用Notification来添加视图时,将需要的信息包装成PushViewItemi 111 | /// ```swift 112 | /// NotificationCenter.default.post( 113 | /// name: .NavigationViewManagerPushView, 114 | /// object: NavigationViewManager.PushViewItem(tag: "test2", animated: false){ 115 | /// AnyView( 116 | /// Text("abcd") 117 | /// ) 118 | /// } ) 119 | /// ``` 120 | public struct PushViewItem { 121 | public init(tag: String, animated: Bool, @ViewBuilder view: @escaping () -> AnyView) { 122 | self.tag = tag 123 | self.animated = animated 124 | self.view = view 125 | } 126 | 127 | let tag: String 128 | let animated: Bool 129 | var view: () -> AnyView 130 | } 131 | 132 | /// 使用Notification来返回根视图,将所需信息包装成BackToRootItem 133 | /// ```swift 134 | /// NotificationCenter.default.post( 135 | /// name: .NavigationViewManagerBackToRoot, 136 | /// object: NavigationViewManager.BackToRootItem(tag: "test2", animated: true, 137 | /// action: { 138 | /// print("other action") 139 | /// })) 140 | /// ``` 141 | public struct BackToRootItem { 142 | public init(tag: String, animated: Bool, action: @escaping () -> Void) { 143 | self.tag = tag 144 | self.animated = animated 145 | self.action = action 146 | } 147 | 148 | let tag: String 149 | let animated: Bool 150 | var action: () -> Void 151 | } 152 | } 153 | 154 | public extension Notification.Name { 155 | static let NavigationViewManagerBackToRoot = Notification.Name(rawValue: "NavigationViewManagerBackToRoot") 156 | static let NavigationViewManagerPushView = Notification.Name(rawValue: "NavigationViewManagerPushView") 157 | } 158 | 159 | public struct NavigationgViewManagerKey: EnvironmentKey { 160 | public static var defaultValue: Binding = .constant(NavigationViewManager()) 161 | } 162 | 163 | public extension EnvironmentValues { 164 | var navigationManager: Binding { 165 | get { self[NavigationgViewManagerKey.self] } 166 | set { self[NavigationgViewManagerKey.self] = newValue } 167 | } 168 | } 169 | 170 | public struct CurrentNavigationViewName: EnvironmentKey { 171 | public static var defaultValue: String? 172 | } 173 | 174 | public extension EnvironmentValues { 175 | var currentNaviationViewName: String? { 176 | get { self[CurrentNavigationViewName.self] } 177 | set { self[CurrentNavigationViewName.self] = newValue } 178 | } 179 | } 180 | 181 | // -MARK: NavigationViewManager 182 | public struct AllowPopToRoot: ViewModifier { 183 | let tag: String 184 | var cleanAction: () -> Void 185 | init(tag: String, cleanAction: @escaping () -> Void = {}) { 186 | self.tag = tag 187 | self.cleanAction = cleanAction 188 | } 189 | 190 | @Environment(\.navigationManager) var nvManager 191 | public func body(content: Content) -> some View { 192 | content 193 | .introspectNavigationController { nv in 194 | nvManager.wrappedValue.addController(controller: nv, tag: tag, cleanAction: cleanAction) 195 | } 196 | .environment(\.currentNaviationViewName, tag) 197 | } 198 | } 199 | 200 | public extension View { 201 | func navigationViewManager(for tag: String,afterBackDo cleanAction: @escaping () -> Void = {}) -> some View { 202 | modifier(AllowPopToRoot(tag: tag, cleanAction: cleanAction)) 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /Sources/NavigationViewKit/NavigationViewManagerMisc.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // File 4 | // 5 | // Created by Yang Xu on 2021/9/1. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | public extension NavigationViewManager{ 12 | /// 返回按钮显示方式 13 | /// - Parameters: 14 | /// - tag: NavigationView的名字(自定义)。用于分辨同一app中的多个NavigationView 15 | /// - mode: 显示模式 16 | /// 17 | /// 在需要设置的视图中调用本方法,该设置仅对本视图有效。 18 | /// ```swift 19 | /// @Environment(\.navigationManager) var nvmanager 20 | /// 21 | /// .onAppear{ 22 | /// nvmanager.wrappedValue.backButtonDisplayMode(for: "nv1", mode: .minimal) 23 | /// } 24 | /// ``` 25 | func backButtonDisplayMode(for tag:String,mode:UINavigationItem.BackButtonDisplayMode) { 26 | contorllers[tag]?.controller.navigationBar.topItem?.backButtonDisplayMode = mode 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/NavigationViewKit/TipOnceDoubleColumnNavigationViewStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TipOnceDoubleColumnNavigationViewStyle.swift 3 | // 4 | // 5 | // Created by Yang Xu on 2021/8/31. 6 | // 7 | 8 | import Foundation 9 | import Introspect 10 | import SwiftUI 11 | 12 | /// 当iPad采用双列style时,第一次竖屏显示时,边栏会自动显示提醒用户 13 | public struct TipOnceDoubleColumnNavigationViewStyle: NavigationViewStyle { 14 | public init() {} 15 | 16 | public func _columnBasedBody(configuration: _NavigationViewStyleConfiguration) -> EmptyView { 17 | EmptyView() 18 | } 19 | 20 | @StateObject var orientation = DeviceOrientation() 21 | @State var show = false 22 | public func _body(configuration: _NavigationViewStyleConfiguration) -> some View { 23 | NavigationView { 24 | configuration.content 25 | } 26 | .navigationViewStyle(DoubleColumnNavigationViewStyle()) 27 | .introspectNavigationController { nv in 28 | if !show { 29 | if orientation.orientation == .portrait { 30 | nv.splitViewController?.preferredDisplayMode = .oneOverSecondary 31 | show = true 32 | } 33 | } else { 34 | nv.splitViewController?.preferredDisplayMode = .automatic 35 | } 36 | } 37 | } 38 | } 39 | 40 | public extension NavigationViewStyle where Self == TipOnceDoubleColumnNavigationViewStyle { 41 | static var tipColumns: TipOnceDoubleColumnNavigationViewStyle { TipOnceDoubleColumnNavigationViewStyle() } 42 | } 43 | -------------------------------------------------------------------------------- /Tests/NavigationViewKitTests/NavigationViewKitTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import NavigationViewKit 3 | 4 | final class NavigationViewKitTests: XCTestCase { 5 | func testExample() { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | //XCTAssertEqual(NavigationViewKit().text, "Hello, World!") 10 | } 11 | } 12 | --------------------------------------------------------------------------------