折叠 SwiftUI 中的 doubleColumn NavigationView 详细信息,就像在 UISplitViewController 上折叠一样?

Posted

技术标签:

【中文标题】折叠 SwiftUI 中的 doubleColumn NavigationView 详细信息,就像在 UISplitViewController 上折叠一样?【英文标题】:Collapse a doubleColumn NavigationView detail in SwiftUI like with collapsed on UISplitViewController? 【发布时间】:2019-07-25 23:38:14 【问题描述】:

因此,当我在 SwiftUI 中创建列表时,我会“免费”获得主从拆分视图。

例如:

import SwiftUI

struct ContentView : View 
    var people = ["Angela", "Juan", "Yeji"]

    var body: some View 
        NavigationView 
            List 
                ForEach(people, id: \.self)  person in
                    NavigationLink(destination: Text("Hello!")) 
                        Text(person)
                    
                
            
            Text("????")
        
    


#if DEBUG
struct ContentView_Previews : PreviewProvider 
    static var previews: some View 
        ContentView()
    

#endif

如果 iPad 模拟器是横向的,我会得到一个 splitView,并且第一个细节屏幕是表情符号。但是如果人们点击一个名字,详细视图是“你好!”

一切都很棒。

但是,如果我纵向运行 iPad,用户会看到表情符号,然后没有迹象表明存在列表。您必须从左向右滑动才能使列表从侧面显示。

有没有人知道如何让导航栏出现,让用户点击以查看左侧的项目列表?所以它不是只有表情符号的屏幕?

我不想留下一个说明“从左侧滑动以查看文件/人员/任何内容的列表”

我记得 UISplitViewController 有一个可以设置的折叠属性。这里有类似的吗?

【问题讨论】:

您找到真正的解决方案了吗?因为我在 macOS 上遇到了同样的问题,并且建议的解决方法不再起作用…… 还没有真正的解决方案@K.Biermann @K.Biermann 我使用带有 .navigationViewStyle(DoubleColumnNavigationViewStyle()) 修饰符的 NavigationView 做了一个快速的 macOS 项目,我得到一个拆分视图就好了。我错过了什么吗? Xcode 11.4-beta 修复了这个问题 - 现在左上方有一个按钮,指示您可以打开主视图! @K.Biermann 我解决了。请参阅我在下面发布的代码。 【参考方案1】:

Xcode 11 beta 3 中,Apple 已将 .navigationViewStyle(style:) 添加到 NavigationView

Xcode 11 Beta 5 更新。 创建 MasterView()DetailsView()

struct MyMasterView: View 

    var people = ["Angela", "Juan", "Yeji"]

    var body: some View 

        List 
            ForEach(people, id: \.self)  person in
                NavigationLink(destination: DetailsView()) 
                    Text(person)
                
            
        

    


struct DetailsView: View 

    var body: some View 
        Text("Hello world")
            .font(.largeTitle)
    

在我的 ContentView 中:

var body: some View 

        NavigationView 

            MyMasterView()

            DetailsView()

        .navigationViewStyle(DoubleColumnNavigationViewStyle())
         .padding()
    

输出:

【讨论】:

填充,嗯?那太随意了。我很快就会试一试! 我试过了,看起来,一旦添加了填充,就无法关闭侧边栏,这是使主视图全屏显示的必要行为。所以它仍然不像我希望的倒塌的财产。您可以使用变量将填充设置为 0 以使侧边栏可关闭,但似乎没有像 NavigationLink 这样的东西来触发它的好方法。无论如何,这都有点hacky,所以我可能需要通过反馈报告并等待下一个测试版。 这不是一个解决方案,因为(正如预期的那样)它向外部添加了填充。这意味着视图不能使用整个屏幕。 我设置了.padding(.leading, 1) 并且效果很好。谢谢! 我在一个新项目中测试了这段代码,但这不起作用。 Master 不会与 Detail 并排显示。发生的情况是细节显示在右上角有一个后退按钮,就是这样。您仍然需要从右侧滑动才能看到大师。还尝试了.padding(.leading, 1) 的建议,但这也不起作用。运行 XCode 12.5.1【参考方案2】:

目前,在 Xcode 11.2.1 中仍然没有任何变化。 我在 iPad 上使用 SplitView 时遇到了同样的问题,并通过在 Ketan Odedra 响应中添加填充来解决它,但对其进行了一些修改:

var body: some View 
    GeometryReader  geometry in
        NavigationView 
            MasterView()
            DetailsView()
        
        .navigationViewStyle(DoubleColumnNavigationViewStyle())
        .padding(.leading, leadingPadding(geometry))
    


private func leadingPadding(_ geometry: GeometryProxy) -> CGFloat 
    if UIDevice.current.userInterfaceIdiom == .pad 
        return 0.5
    
    return 0

这在模拟器中完美运行。但是当我提交我的应用程序进行审核时,它被拒绝了。这个小技巧在审阅者设备上不起作用。我没有真正的 iPad,所以我不知道是什么原因造成的。试试吧,也许它对你有用。

虽然它对我不起作用,但我向 Apple DTS 请求了帮助。 他们回应我说,目前 SwiftUI API 无法完全模拟 UIKit 的 SplitViewController 行为。但是有一个解决方法。 您可以在 SwiftUI 中创建自定义 SplitView:

struct SplitView<Master: View, Detail: View>: View 
    var master: Master
    var detail: Detail

    init(@ViewBuilder master: () -> Master, @ViewBuilder detail: () -> Detail) 
        self.master = master()
        self.detail = detail()
    

    var body: some View 
        let viewControllers = [UIHostingController(rootView: master), UIHostingController(rootView: detail)]
        return SplitViewController(viewControllers: viewControllers)
    


struct SplitViewController: UIViewControllerRepresentable 
    var viewControllers: [UIViewController]
    @Environment(\.splitViewPreferredDisplayMode) var preferredDisplayMode: UISplitViewController.DisplayMode

    func makeUIViewController(context: Context) -> UISplitViewController 
        return UISplitViewController()
    

    func updateUIViewController(_ splitController: UISplitViewController, context: Context) 
        splitController.preferredDisplayMode = preferredDisplayMode
        splitController.viewControllers = viewControllers
    


struct PreferredDisplayModeKey : EnvironmentKey 
    static var defaultValue: UISplitViewController.DisplayMode = .automatic


extension EnvironmentValues 
    var splitViewPreferredDisplayMode: UISplitViewController.DisplayMode 
        get  self[PreferredDisplayModeKey.self] 
        set  self[PreferredDisplayModeKey.self] = newValue 
    


extension View 
    /// Sets the preferred display mode for SplitView within the environment of self.
    func splitViewPreferredDisplayMode(_ mode: UISplitViewController.DisplayMode) -> some View 
        self.environment(\.splitViewPreferredDisplayMode, mode)
    

然后使用它:

SplitView(master: 
            MasterView()
        , detail: 
            DetailView()
        ).splitViewPreferredDisplayMode(.allVisible)

在 iPad 上,它可以工作。但是有一个问题(也许更多..)。 这种方法会破坏 iPhone 上的导航,因为 MasterView 和 DetailView 都有自己的 NavigationView。

更新: 最后,在 Xcode 11.4 beta 2 中,他们在导航栏中添加了一个按钮,指示隐藏的主视图。

【讨论】:

旋转(当horizontalSizeClass == .compact)会导致问题...【参考方案3】:

在模拟器中进行最少的测试,但这应该接近真正的解决方案。这个想法是使用EnvironmentObject 来保存一个已发布的变量,以确定是使用双列NavigationStyle 还是单列,然后在该变量发生变化时重新创建NavigationView

环境对象:

  final class AppEnvironment: ObservableObject 
    @Published var useSideBySide: Bool = false
  

在场景委托中,在启动时设置变量,然后观察设备旋转并可能更改它(“1000”不是正确的值,起点):

class SceneDelegate: UIResponder, UIWindowSceneDelegate 
    var appEnvironment = AppEnvironment()

    @objc
    func orientationChanged() 
        let bounds = UIScreen.main.nativeBounds
        let orientation = UIDevice.current.orientation

        // 1000 is a starting point, should be smallest height of a + size iPhone
        if orientation.isLandscape && bounds.size.height > 1000 
            if appEnvironment.useSideBySide == false 
                appEnvironment.useSideBySide = true
                print("SIDE changed to TRUE")
            
         else if orientation.isPortrait && appEnvironment.useSideBySide == true 
            print("SIDE changed to false")
            appEnvironment.useSideBySide = false
        
    

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) 
        let contentView = ContentView()

        if let windowScene = scene as? UIWindowScene 
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: contentView.environmentObject(appEnvironment))
            self.window = window
            window.makeKeyAndVisible()

            orientationChanged()
            NotificationCenter.default.addObserver(self, selector: #selector(orientationChanged), name: UIDevice.orientationDidChangeNotification, object: nil)
            UIDevice.current.beginGeneratingDeviceOrientationNotifications()
        

在创建NavigationView 的***内容视图中,使用自定义修饰符而不是直接使用navigationViewStyle

struct ContentView: View 
    @State private var dates = [Date]()

    var body: some View 
        NavigationView 
            MV(dates: $dates)
            DetailView()
        
        .modifier( WTF() )
    

    struct WTF: ViewModifier 
        @EnvironmentObject var appEnvironment: AppEnvironment

        func body(content: Content) -> some View  
            Group 
                if appEnvironment.useSideBySide == true 
                    content
                        .navigationViewStyle(DoubleColumnNavigationViewStyle())
                 else 
                    content
                        .navigationViewStyle(StackNavigationViewStyle())
                
            
        
    

如前所述,只是模拟器测试,但我尝试在两个方向上启动,显示 Master 旋转,显示 Detail 旋转,对我来说一切都很好。

【讨论】:

我正在使用您的方法,但是当从横向旋转到纵向时,整个视图会刷新并显示主视图,在我的例子中是一个列表。我想从横向显示选定的详细视图,您认为这可能吗? @laucel 去年当我接受一份新工作时,我不得不放弃学习 Swiftui。很抱歉,帮不上忙。【参考方案4】:

对于当前版本(ios 13.0-13.3.x),您可以使用我的代码。 我使用 UIViewUpdater 访问底层 UIView 及其 UIViewController 来调整栏项。

我认为解决这个问题的 UIViewUpdater 方式是最 Swifty 和健壮的方式,你可以使用它来访问和修改其他 UIView、UIViewController 相关的 UIKit 机制。

ContentView.swift

import SwiftUI

struct ContentView : View 
    var people = ["Angela", "Juan", "Yeji"]

    var body: some View 
        NavigationView 
            List 
                ForEach(people, id: \.self)  person in
                    NavigationLink(destination: DetailView())  Text(person) 
                
            

            InitialDetailView()

        
    


struct DetailView : View 
    var body: some View 
        Text("Hello!")
    


struct InitialDetailView : View 
    var body: some View 
        NavigationView 
            Text("?")
                .navigationBarTitle("", displayMode: .inline) // .inline is neccesary for showing the left button item
                .updateUIViewController 
                    $0.splitViewController?.preferredDisplayMode = .primaryOverlay // for showing overlay at initial
                    $0.splitViewController?.preferredDisplayMode = .automatic
                
                .displayModeButtonItem()
        .navigationViewStyle(StackNavigationViewStyle())
    


#if DEBUG
struct ContentView_Previews : PreviewProvider 
    static var previews: some View 
        ContentView()
    

#endif

解决方案的实用程序代码。将它放在项目的任何 Swift 文件中。 Utility.swift

// View decoration

public extension View 
    func updateUIView(_ action: @escaping (UIView) -> Void) -> some View 
        background(UIViewUpdater(action: action).opacity(0))
    

    func updateUIViewController(_ action: @escaping (UIViewController) -> Void) -> some View 
        updateUIView 
            guard let viewController = $0.viewController else  return 
            action(viewController)
        
    

    func displayModeButtonItem(_ position: NavigationBarPostion = .left) -> some View 
        updateUIViewController  $0.setDisplayModeButtonItem(position) 
    


// UpdateUIView


struct UIViewUpdater : UIViewRepresentable 
    let action: (UIView) -> Void
    typealias UIViewType = InnerUIView

    func makeUIView(context: UIViewRepresentableContext<Self>) -> UIViewType 
        UIViewType(action: action)
    

    func updateUIView(_ uiView: UIViewType, context: UIViewRepresentableContext<Self>) 
        // DispatchQueue.main.async  [action] in action(uiView) 
    

    class InnerUIView : UIView 
        let action: (UIView) -> Void
        init(action: @escaping (UIView) -> Void) 
            self.action = action
            super.init(frame: .zero)
        

        required init?(coder: NSCoder) 
            fatalError("init(coder:) has not been implemented")
        

        override func didMoveToWindow() 
            super.didMoveToWindow()
            update()
        

        func update() 
            action(self)
        
    


// UIView.viewController

public extension UIView 
    var viewController: UIViewController? 
        var i: UIResponder? = self
        while i != nil 
            if let vc = i as? UIViewController  return vc 
            i = i?.next
        
        return nil
    


// UIViewController.setDisplayModeButtonItem

public enum NavigationBarPostion 
    case left
    case right


public extension UIViewController 
    func setDisplayModeButtonItem(_ position: NavigationBarPostion) 
        guard let splitViewController = splitViewController else  return 
        switch position 
        case .left:
            // keep safe to avoid replacing other left bar button item, e.g. navigation back
            navigationItem.leftItemsSupplementBackButton = true
            navigationItem.leftBarButtonItem = splitViewController.displayModeButtonItem
        case .right:
            navigationItem.rightBarButtonItem = splitViewController.displayModeButtonItem
        
    

【讨论】:

重点是能够在不将所有内容包装在 UIKit 中的情况下工作。否则,我只会将 UISplitViewController 包装在 UIViewControllerRepresentable 中。 此代码没有包装新的 UISplitVC。它只是让您有机会操纵底层的 UISplitVC。这是目前唯一的方法。那你怎么能不碰 UIKit 就改变显示模式呢? @MScottWaller 它确实解决了 iOS 13.0 - 13.3 中的问题。在当前的 SwiftUI NavigationView 机制下,它是初始显示 primaryOverlay 的唯一方式,并提供了让您选择显示模式(例如 allVisible)的机会,比包装任何新的 UISplitVC 更好,因为它会放弃使用 DoubleColumnNavigationStyle。 所以,我们正在寻找一种完全不使用 UIKit 的方法。现在有一个底层的 UISplitVC,但也许将来不会有。现在 UIKit 是一个中间抽象,但也许苹果将来会删除 UIKit 作为中间抽象,或者至少使用一些新的东西,而不是 UISplitVC 来处理主细节。在这两种情况下,您的代码都会中断。关键是,我们只想使用 SwiftUI 来做到这一点,而不是通过混合 UIKit。 @MScottWaller 如果你想找到一个 SwiftUI 唯一的方法(并请在你的问题中宣布它)那么没有办法,因为没有 SwiftUI API。【参考方案5】:
import SwiftUI

var hostingController: UIViewController?

func showList() 
    let split = hostingController?.children[0] as? UISplitViewController
    UIView.animate(withDuration: 0.3, animations: 
        split?.preferredDisplayMode = .primaryOverlay
    )  _ in
        split?.preferredDisplayMode = .automatic
    


func hideList() 
    let split = hostingController?.children[0] as? UISplitViewController
    split?.preferredDisplayMode = .primaryHidden


// =====

struct Dest: View 
    var person: String

    var body: some View 
        VStack 
            Text("Hello! \(person)")
            Button(action: showList) 
                Image(systemName: "sidebar.left")
            
        
        .onAppear(perform: hideList)
    


struct ContentView : View 
    var people = ["Angela", "Juan", "Yeji"]

    var body: some View 
        NavigationView 
            List 
                ForEach(people, id: \.self)  person in
                    NavigationLink(destination: Dest(person: person)) 
                        Text(person)
                    
                
            
            VStack 
                Text("?")
                Button(action: showList) 
                    Image(systemName: "sidebar.left")
                
            
        
    


import PlaygroundSupport
hostingController = UIHostingController(rootView: ContentView())
PlaygroundPage.current.setLiveView(hostingController!)

【讨论】:

以上是关于折叠 SwiftUI 中的 doubleColumn NavigationView 详细信息,就像在 UISplitViewController 上折叠一样?的主要内容,如果未能解决你的问题,请参考以下文章

SwiftUI 动画列表行的展开和折叠

在 HStack 中有 2 个 List/ScrollView 时,NavigationView 不会折叠 - SwiftUI

在 SwiftUI (Xcode 12) 中折叠侧边栏

如何在 SwiftUI 中制作“显示”式的折叠/展开动画?

如何在 SwiftUI 中结合可折叠行使用 List 自定义行内部内容?

SwiftUI 中的绑定无法在循环内工作