随着苹果对 iPadOS 的不断投入,越来越多的开发者都期望自己的运用能够在 iPad 中有更好的体现。尤其当用户开启了台前调度( Stage Manager )功能后,运用对不同视觉巨细形式的兼容才能就越发显得重要。本文将就怎么创建可自适应不同尺度形式的程序化导航计划这一内容进行评论。

原文宣布在我的博客 wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】

iShot_2022-11-13_09.30.17.2022-11-13 09_35_46

程序化导航与状况驱动

顾名思义,“程序化导航”便是开发者能够经过代码感知运用当时的导航状况并设置导航目标的办法。从 4.0 版本开始,苹果对之前 SwiftUI 有限的程序化导航才能进行了大幅度的增强,经过引进 NavigationStack 和 NavigationSplitView,开发者基本上具有了全程把握运用的导航状况的才能,并可在视图表里的代码中完成恣意方位的跳转。

与 UIKit 运用的指令式导航办法不同,SwiftUI 作为一个声明式框架,感知与设置两者之间是二位一体的联系。读取状况即可获知当时的导航方位,更改状况便可调整导航路径。因而在 SwiftUI 中,把握两种导航容器的状况表述差异是完成自适应导航计划的要害。

NavigationStack vs NavigationSplitView

本节仅对 NavigationStack 和 NavigationSplitView 之间的状况表述进行阐明,想了解两者详细用法,请参阅 SwiftUI 4.0 的全新导航系统 一文。

与视觉体现一致, NavigationStack 用“栈”作为导航的状况表述。运用数组( NavigationPath 也是对 Hashable 数组的一种包装 )作为状况的体现形式。在栈中推送和弹出数据的进程对应了导航容器中增加和移除视图的操作。弹出悉数数据相当于回来根视图,推送多个数据相当于一次性增加多个视图并直接跳转到最后数据所代表的视图。需求特别注意的是,在 NavigationStack 中,根视图是直接经过代码声明的,并不存在于“栈”中。

咱们能够将 NavigationSplitView 视为具有一些预置才能的 HStack,经过在其间声明两个或三个视图然后创建两列或三列的导航界面。在不少状况下,NavigationSplitView 与 具有多个视图的 HStack 之间的状况表述十分相似。但是,因为 NavigationSplitView 的某些特性,然后对状况的表述有更多的要求和约束:

  • 在需求的状况下( iPhone 或 compact 形式下 )能够主动转化成 NavigationStack 的视觉状况

    关于一些简单的两列或三列的导航布局,SwiftUI 能够主动将其转化成 NavigationStack 体现形式。下文中的计划一和计划二便是对这种才能的体现。但并非一切的状况表述都可在转化后完成程序化导航。

  • 与 List 进行了深度的绑定

    关于一个包括三列( A、B、C )的 NavigationSplitView ,咱们能够运用恣意的办法让这些视图之间产生联动。例如:在 A 中修正状况 b,B 呼应 b 状况;在 B 中修正状况 c,C 视图呼应状况 c。不过仅有在前两列中经过 List(selection:) 来修正状况时,才能在主动转化的 NavigationStack 体现形式中具有程序化导航的才能。计划一对此有进一步的阐明。

  • 列中能够进一步嵌入 NavigationStack

    咱们能够在 NavigationSplitView 的恣意列中嵌入 NavigationStack 然后完成愈加复杂的导航机制。但如此一来,主动转化将无法应对这类的场景。开发者需求自行对两种导航逻辑的状况进行转化。计划三将演示怎么进行这一进程。

最易用的计划 —— NavigationSplitView + List

navigationSplitView-three_38_14

struct ThreeColumnsView: View {
    @StateObject var store = ThreeStore()
    @State var visible = NavigationSplitViewVisibility.all
    var body: some View {
        VStack {
            NavigationSplitView(columnVisibility: $visible, sidebar: {
                List(selection: Binding<Int?>(get: { store.contentID }, set: {
                    store.contentID = $0
                    store.detailID = nil
                })) {
                    ForEach(0..<100) { i in
                        Text("SideBar \(i)")
                    }
                }
                .id(store.deselectSeed)
            }, content: {
                List(selection: $store.detailID) {
                    if let contentID = store.contentID {
                        ForEach(0..<100) { i in
                            Text("\(contentID):\(i)")
                        }
                    }
                }
                .overlay {
                    if store.contentID == nil {
                        Text("Empty")
                    }
                }
            }, detail: {
                if let detailID = store.detailID {
                    Text("\(detailID)")
                } else {
                    Text("Empty")
                }
            })
            .navigationSplitViewStyle(.balanced)
            HStack {
                Button("Back Root") {
                    store.backRoot()
                }
                Button("Back Parent") {
                    store.backParent()
                }
            }
            .buttonStyle(.bordered)
        }
    }
}
class ThreeStore: ObservableObject {
    @Published var contentID: Int?
    @Published var detailID: Int?
    @Published var deselectSeed = 0
    func backParent() {
        if detailID != nil {
            detailID = nil
        } else if contentID != nil {
            contentID = nil
        }
    }
    func backRoot() {
        detailID = nil
        contentID = nil
        // 改进 compact 形式下回来根目录后的体现。撤销选中高亮
        // 能够用相似的办法,改进当 contentID 改变后,content 列仍会有灰色选择提示的问题
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            withAnimation {
                self.deselectSeed += 1
            }
        }
    }
}

代码很简单,我仅就几点进行提示:

  • List 有必要呈现在列代码的最上层

    为了保证在主动转化后仍具有程序化导航的才能,NavigationSplitView 对嵌入的 List 有严格的要求,List 代码有必要呈现在列代码中的最上层。比如在本例的 Content 列代码中,为了维持这个限定,只能经过 overlay 来定义占位视图。假如将代码调整成如下款式,则会在转化后损失程序化导航的才能( 无法经过修正状况,回来上层视图 )。

if store.detailID != nil {
    List(selection: $store.detailID)
} else {
    Text("Empty")
}
  • 修正状况后,List 仍会用灰色显现上次选中的项目

    即便撤销了状况( 例如修正 contentID ),List 仍会将上次选中的状况用灰色的选中框进行表明。为了避免运用者产生误解,代码中分别运用了两个 id 润饰器在状况改变后对列视图进行了刷新。

有得必有失 —— NavigationSplitView + LazyVStack

尽管 List 运用起来很简单,但也有一些缺乏之处,其间最重要的是无法自定义选中的状况。那么能否在导航列中运用 VStack 或 LazyVStack 完成程序化导航呢?

在不久前的 Ask Apple 中,苹果工程师介绍了如下的办法:

image-20221114135939796

很遗憾,因为没有暴露 path 接口,问答中的 navigationDestination(for:) 无法完成程序化的回退。不过咱们能够经过运用另一个 navigationDestination(isPresented:) 润饰器来到达相似的目的。俗话说,有得必有失,暂时这种办法只能支撑两列,没有找到能够在中心列中继续运用程序化导航的办法。

navigationSplitView-two-_52

class TwoStore: ObservableObject {
    @Published var detailID: Int?
    func backParent() {
        detailID = nil
    }
}
struct TowColumnsView: View {
    @StateObject var store = TwoStore()
    @State var visible = NavigationSplitViewVisibility.all
    var body: some View {
        VStack {
            NavigationSplitView(columnVisibility: $visible, sidebar: {
                ScrollView {
                    LazyVStack {
                        ForEach(0..<100) { i in
                            Text("SideBar \(i)")
                                .padding(5)
                                .padding(.leading, 10)
                                .frame(maxWidth: .infinity, alignment: .leading)
                                .background(store.detailID == i ? Color.blue : .clear)
                                .contentShape(Rectangle())
                                .onTapGesture {
                                    store.detailID = i
                                }
                        }
                    }
                }
                .navigationDestination(
                    isPresented: Binding<Bool>(
                        get: { store.detailID != nil },
                        set: { if !$0 { store.detailID = nil }}
                    ),
                    destination: {
                        // 需求运用独立的 struct 来构造视图
                        DetailView(store: store)
                    }
                )
            }, detail: {
                Text("Empty")
            })
            Button("Back Parent") {
                store.backParent()
            }
            .buttonStyle(.bordered)
        }
    }
}
struct DetailView: View {
    @ObservedObject var store: TwoStore
    var body: some View {
        if let detailID = store.detailID {
            Text("\(detailID)")
        }
    }
}

需求特别提示的是,因为处在不同的上下文中,在 navigationDestination 的 destination 中,有必要用单独的 struct 来创建视图。不然视图无法呼应状况的改变。

麻烦但最能打 —— NavigationSplitView + NavigationStack

假如上述两个计划仍无法满足你的需求,那么便需求依据当时的视觉巨细形式选择性调用 NavigatoinStack 或 NavigationSplitView。

例如,下面的代码完成了一个具有两列的 NavigationSplitView ,Detail 列中包括一个 NavigationStack。在 InterfaceSizeClass 产生改变后,需求对导航状况进行调整,以匹配 NavigationStack 的需求。反之亦然。演示图片见本文第一个动图。

class AdaptiveStore: ObservableObject {
    @Published var detailPath = [DetailInfo]() {
        didSet {
            if sizeClass == .compact, detailPath.isEmpty {
                rootID = nil
            }
        }
    }
    @Published var rootID: Int?
    var sizeClass: UserInterfaceSizeClass? {
        didSet {
            if oldValue != nil, oldValue != sizeClass, let oldValue, let sizeClass {
                rebuild(from: oldValue, to: sizeClass)
            }
        }
    }
    func backRoot() {
        detailPath.removeAll()
    }
    func backParent() {
        if !detailPath.isEmpty {
            detailPath.removeLast()
        }
    }
    func selectRootID(rootID: Int) {
        if sizeClass == .regular {
            self.rootID = rootID
            detailPath.removeAll()
        } else {
            self.rootID = rootID
            detailPath.append(.init(level: 1, rootID: rootID))
        }
    }
    func rebuild(from: UserInterfaceSizeClass, to: UserInterfaceSizeClass) {
        guard let rootID else { return }
        if to == .regular {
            if !detailPath.isEmpty {
                detailPath.removeFirst()
            }
        } else {
            detailPath = [.init(level: 1, rootID: rootID)] + detailPath
        }
    }
}
struct DetailInfo: Hashable, Identifiable {
    let id = UUID()
    let level: Int
    let rootID: Int
}
struct AdaptiveNavigatorView: View {
    @StateObject var store = AdaptiveStore()
    @Environment(\.horizontalSizeClass) var sizeClass
    var body: some View {
        VStack {
            if sizeClass == .regular {
                SplitView(store: store)
                    .task {
                        store.sizeClass = sizeClass
                    }
            } else {
                StackView(store: store)
                    .task {
                        store.sizeClass = sizeClass
                    }
            }
            HStack {
                Button("Back Root") { store.backRoot() }
                Button("Back Parent") { store.backParent() }
            }
            .buttonStyle(.bordered)
        }
    }
}
struct SplitView: View {
    @ObservedObject var store: AdaptiveStore
    var body: some View {
        NavigationSplitView {
            SideBarView(store: store)
        } detail: {
            if let rootID = store.rootID {
                NavigationStack(path: $store.detailPath) {
                    DetailInfoView(store: store, info: .init(level: 1, rootID: rootID))
                        .navigationTitle("Root \(rootID), Level:\(store.detailPath.count + 1)")
                        .navigationDestination(for: DetailInfo.self) { info in
                            DetailInfoView(store: store, info: info)
                                .navigationTitle("Root \(info.rootID), Level \(info.level)")
                        }
                }
            } else {
                Text("Empty")
            }
        }
    }
}
struct StackView: View {
    @ObservedObject var store: AdaptiveStore
    var body: some View {
        NavigationStack(path: $store.detailPath) {
            SideBarView(store: store)
                .navigationDestination(for: DetailInfo.self) { info in
                    DetailInfoView(store: store, info: info)
                        .navigationTitle("Root \(info.rootID), Level \(info.level)")
                }
        }
    }
}
struct DetailInfoView: View {
    @ObservedObject var store: AdaptiveStore
    let info: DetailInfo
    var body: some View {
        List {
            Text("RootID:\(info.rootID)")
            Text("Current Level:\(info.level)")
            NavigationLink("Goto Next Level", value: DetailInfo(level: info.level + 1, rootID: info.rootID))
                .foregroundColor(.blue)
        }
    }
}
struct SideBarView: View {
    @ObservedObject var store: AdaptiveStore
    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(0..<30) { rootID in
                    Button {
                        store.selectRootID(rootID: rootID)
                    }
                label: {
                        Text("RootID \(rootID)")
                            .padding(5)
                            .padding(.leading, 10)
                            .frame(maxWidth: .infinity, alignment: .leading)
                            .contentShape(Rectangle())
                            .background(store.rootID == rootID ? .cyan : .clear)
                    }
                }
            }
            .buttonStyle(.plain)
        }
        .navigationTitle("Root")
    }
}

请注意如下几点:

  • 以导航容器地点的视图的 horizontalSizeClass 为判断规范

    InterfaceSizeClass 对应的是当时视图的视觉巨细。最好以导航容器地点视图的 sizeClass 作为判断规范。例如,在 Side 列视图中,无论在任何环境下,horizontalSizeClass 一直为 compact 。

  • 以导航容器的呈现时机( onAppear )作为重新构建状况的起始点

    sizeClass 在改变的进程中,其间的值可能会呈现重复改变的状况。因而,不应将 sizeClass 的值是否产生改变作为重构状况的判断规范。

  • 不要忘记 NavigationStack 的根视图不在它的“栈”数据中

    在本例中,转化至 NavigationStack 时,需求将 Detail 列中声明的视图增加到“栈”的底端。反过来则将其移除。

本着“一案一议”的准则,当时计划能够完成对恣意的导航逻辑进行转化。

总结

能够在 此处 获取本文的悉数代码。

一次编写便可对应多种设备,这本便是 SwiftUI 的一个重要特色。尽管仍存在一些缺乏,但新的导航机制已经在这一方面取得了长足的进步。仅有遗憾的是,仅支撑 iOS 16+。

期望本文能够对你有所协助。同时也欢迎你经过 Twitter、 Discord 频道 或博客的留言板与我进行沟通。

我正以聊天室、Twitter、博客留言等评论为创意,从中选取有代表性的问题和技巧制作成 Tips ,发布在 Twitter 上。每周也会对当周博客上的新文章以及在 Twitter 上发布的 Tips 进行汇总,并经过邮件列表的形式发送给订阅者。

订阅下方的 邮件列表,能够及时取得每周的 Tips 汇总。

原文宣布在我的博客 wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】