0%

SwiftUI中NavigationView的问题

本人已经使用 SwiftUI 开发了 简习录, 并修复了若干 BUG . 在这些 BUG 中让人头痛的当属 NavigtaionView 了, 这篇文章将会向您报告其中的问题;

# 使用 UINavigationBarAppearance

在普通情况下我们会在 application(_, didFinishLaunchingWithOptions) 使用如下代码来对 UINavigationBar 进行配置:

UINavigationBar.appearance().isTranslucent = false
UINavigationBar.appearance().barTintColor = UIColor(named: "themeColor")
UINavigationBar.appearance().shadowImage = UIImage()
...

对于 SwiftUIiOS 13.4这些代码也会起到作用, 但是:

  • iOS 13.3 将会直接引起崩溃,并且通过crash文件无法分析到原因;
  • 如果在代码中一旦对 NavigationView 设置 .navigationViewStyle(StackNavigationViewStyle()) ,那么不论什么系统版本都会在这个 NavigationView 将要展示的时候直接崩溃;

这里推荐使用 iOS 13 的新 API 进行设置,带有注释的代码示例如下:

let coloredAppearance = UINavigationBarAppearance()
// 设置为不透明
coloredAppearance.configureWithOpaqueBackground()
// 设置背景色(对于纯色的背景可以使用 backgroundColor 而不需要设置 backgroundImage )
coloredAppearance.backgroundColor = UIColor(named: "themeColor2")
// 完全可以不用设置 shadowColor, nil 或者 clear 为相同表现
coloredAppearance.shadowColor = .clear
let titleAttr: [NSAttributedString.Key: Any] = [
    .foregroundColor: UIColor.white
]
// 设置 inline 状态下的 title 属性,large 状态对应的是 largeTitleTextAttributes
coloredAppearance.titleTextAttributes = titleAttr
// 这里还可以设置 titlePositionAdjustment 来控制标题的偏移量 UIOffset(horizontal: -10, vertical: 0)

// 设置返回按钮的样式
let backButtonAppearance = UIBarButtonItemAppearance()
// 字体样式
backButtonAppearance.normal.titleTextAttributes = [.foregroundColor: UIColor.white]
coloredAppearance.backButtonAppearance = backButtonAppearance

let backImage = UIImage(named: "back")?.withRenderingMode(.alwaysOriginal)
// 设置返回按钮 箭头 image, 注意两个都要设置任何一个为 nil,则使用系统的
coloredAppearance.setBackIndicatorImage(backImage, transitionMaskImage: backImage)
// 设置标准(large)状态下的外观属性,compactAppearance 如未设置将使用 standardAppearance 的样式
UINavigationBar.appearance().standardAppearance = coloredAppearance
// 描述当相关联的UIScrollView到达与导航栏邻接的边缘(导航栏的顶部边缘)时要使用的导航栏的外观属性。
// 如果未设置,则将使用修改后的standardAppearance。
UINavigationBar.appearance().scrollEdgeAppearance = coloredAppearance

新建一个 SwiftUI 工程,将 ContentView.swift 代码替换如下:

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationView {
            VStack(spacing: 30) {
                NavigationLink(destination: secondView()) {
                    Text("直接使用NavigationLink")
                }
            }
            .navigationBarTitle("Navigation BUG", displayMode: .inline)
            .navigationBarItems(
                trailing: NavigationLink(destination: secondView()) {
                    Text("Link")
            })
        }

    }

    func secondView() -> some View {
        return Text("second").navigationBarTitle("Sencod", displayMode: .inline)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

当你在 iOS 13.4.xiOS 13.1.x 的设备上运行后不论是点击 Link 还是 直接使用NavigationLink, 然后点击返回都不会出现问题(?_? 13.1.? 上可能会出现 NavigationBar 无限叠加的问题,这个没有复现😑);

当你在 iOS 13.2 设备上运行时点击 直接使用NavigationLink 没任何问题, 但是点击 Link 然后返回,会在显示完转场动画后直接崩溃,控制台打印信息为:

*** Assertion failure in -[UINavigationController popToViewController:transition:], /BuildRoot/Library/Caches/com.apple.xbs/Sources/UIKitCore_Sim/UIKit-3900.12.16/UINavigationController.m:8129
 *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Tried to pop to a view controller that doesn't exist.'
 First throw call stack:
(
    0   CoreFoundation                      0x00007fff23c4f02e __exceptionPreprocess + 350
    1   libobjc.A.dylib                     0x00007fff50b97b20 objc_exception_throw + 48
    2   CoreFoundation                      0x00007fff23c4eda8 +[NSException raise:format:arguments:] + 88
    3   Foundation                          0x00007fff256c9b61 -[NSAssertionHandler handleFailureInMethod:object:file:lineNumber:description:] + 191
    4   UIKitCore                           0x00007fff4713d9b1 __57-[UINavigationController popToViewController:transition:]_block_invoke + 620
    5   UIKitCore                           0x00007fff4713d65e -[UINavigationController popToViewController:transition:] + 753
    ...

解决办法是使用 NavigationLink 的另外一种写法:

NavigationLink(destination: secondView(), isActive: $showDetail, label: { EmptyView() })

添加此段代码后的 ContentView struct 的整体代码如下

struct ContentView: View {

    @State private var showDetail = false

    var body: some View {
        NavigationView {
            VStack(spacing: 30) {
                NavigationLink(destination: secondView()) {
                    Text("直接使用NavigationLink")
                }

                NavigationLink(destination: secondView(), isActive: $showDetail, label: { EmptyView() })

                Button(action: {
                    self.showDetail = true
                }) {
                    Text("使用isActive和EmptyView")
                }
            }
            .navigationBarTitle("Navigation BUG", displayMode: .inline)
            .navigationBarItems(
                leading: Button("Empty") {
                    self.showDetail = true
                },
                trailing: NavigationLink(destination: secondView()) {
                    Text("Link")
            })
        }
        .navigationViewStyle(StackNavigationViewStyle())
    }


    func secondView() -> some View {
        return Text("second").navigationBarTitle("Sencod", displayMode: .inline)
    }
}

运行后点击 Empty使用isActive和EmptyView 跳转并返回无任何问题, OK 这里宣告结束? NO! 我们高兴的太早了, 在 iOS 13.3 的设备中运行你会发现如果点击 Link 跳转并返回后再次点击 Link 将不会再跳转, 同样的点击 Empty , 直接使用NavigationLink, 使用isActive和EmptyView, 也是如此!!!

程序是没崩溃可是我已经要崩溃了! 这个问题暂时无解了! 如果您有好的解决办法请务必留言通知,感谢.

开始写到完成程序只花了 4 天不到的时间,可是单是适配个 iOS 13.1, iOS 13.2.x, iOS 13.3 就已经在考验我的发量了;

所以为了不拔高自己的发际线, 在 简习录 第二次的版本更新中, 我不得不指定 iOS 13.4 可用, 但是这并不是完结。

# 在 transition 动画在 NavigationView 中的问题

整体代码如下, 问题件代码注释:

import SwiftUI

fileprivate extension AnyTransition {
    static var moveToOpacity: AnyTransition {
        let insertion = AnyTransition.move(edge: .bottom)
        let removal = AnyTransition.opacity
        return .asymmetric(insertion: insertion, removal: removal)
    }
}


fileprivate class CModel: Identifiable {
    let id = UUID()
}


struct BUGTransition: View {
    var body: some View {
        ShowOrHiddenAnimation2()
    }
}

// NavigationView 内部包裹的 ZStcak 使用 ZStcak ==> transition无问题,navigationBar 无法遮挡
fileprivate struct ShowOrHiddenAnimation4: View {
    @State private var model: CModel? = nil
    var body: some View {
            NavigationView {
                ZStack {
                    Button("Tap Me") {
                        withAnimation(.easeOut(duration: 3)) {
                            self.model = CModel()
                        }
                    }
                    if self.model != nil {
                        FullScreenView1(model: $model)
                    }
                }
                .navigationBarTitle("xxx", displayMode: .inline)
            }
    }
}
// ZStcak 内部包裹 NavigationView 后使用 Group ==> transition的insert无问题removal无效,navigationBar 完美遮挡
fileprivate struct ShowOrHiddenAnimation3: View {
    @State private var model: CModel? = nil
    var body: some View {
        ZStack {
            NavigationView {
                Button("Tap Me") {
                    withAnimation(.easeOut(duration: 3)) {
                        self.model = CModel()
                    }
                }
                .navigationBarTitle("xxx", displayMode: .inline)
            }
            if self.model != nil {
                /*
                Color.pink.edgesIgnoringSafeArea(.all)
                    .transition(.moveToOpacity)
                    .animation(.easeOut(duration: 3))
                    .onTapGesture {
                        withAnimation(.easeOut(duration: 3)) {
                            self.model = nil
                        }
                    }
                */
                FullScreenView2(model: $model)
            }
        }
    }
}
// ZStcak 内部包裹 NavigationView 后使用 ZStack ==> transition的insert无问题removal无效,navigationBar 完美遮挡
fileprivate struct ShowOrHiddenAnimation2: View {
    @State private var model: CModel? = nil
    var body: some View {
        ZStack {
            NavigationView {
                Button("Tap Me") {
                    withAnimation(.easeOut(duration: 3)) {
                        self.model = CModel()
                    }
                }
                .navigationBarTitle("xxx", displayMode: .inline)
            }
            if self.model != nil {
                FullScreenView1(model: $model)
            }
        }
    }
}
// ZStack 内部直接使用 ZStack ==> transition无问题,未使用 navigationBar
fileprivate struct ShowOrHiddenAnimation1: View {
    @State private var model: CModel? = nil
    var body: some View {
        ZStack {
            Button("Tap Me") {
                withAnimation(.easeOut(duration: 3)) {
                    self.model = CModel()
                }
            }
            if self.model != nil {
                FullScreenView1(model: $model)
            }
        }
    }
}

// 内部指定使用 ZStack
fileprivate struct FullScreenView1: View {
    @Binding var model: CModel?
    var body: some View {
        ZStack {
            Color.pink.edgesIgnoringSafeArea(.all)
        }
        .transition(.moveToOpacity)
        .animation(.easeOut(duration: 3))
        .onTapGesture {
            withAnimation(.easeOut(duration: 3)) {
                self.model = nil
            }
        }
    }
}

// 使用 Group 包装,布局由外部决定
fileprivate struct FullScreenView2: View {
    @Binding var model: CModel?
    var body: some View {
        Group {
            Color.pink.edgesIgnoringSafeArea(.all)
            NavigationView {
                Text("FullScreenView2")
            }
        }
        .transition(.moveToOpacity)
        .animation(.easeOut(duration: 3))
        .onTapGesture {
            withAnimation(.easeOut(duration: 3)) {
                self.model = nil
            }
        }
    }
}

struct BUGTransition_Previews: PreviewProvider {
    static var previews: some View {
        BUGTransition()
    }
}

同样的如果你下载了 官方教程demo ,找到

extension AnyTransition {
    static var moveAndFade: AnyTransition {
        let insertion = AnyTransition.move(edge: .trailing)
            .combined(with: .opacity)
        let removal = AnyTransition.scale
            .combined(with: .opacity)
        return .asymmetric(insertion: insertion, removal: removal)
    }
}

运行后也会发现 removal 效果在 NavigationView 中完全失效了🤭,这是官方彩蛋?

# 完结?

这里仅仅是列举了 NavigationView 中的一部分问题,

  • 其他不是很严重的不再赘述;
  • NavigationView 不相关的也不在这篇中描述了(例如, 对使用 UIViewRepresentableView 添加 cornerRadius 修饰符会导致其“失去响应”🤥);

目前 简习录 已经支持 iOS 13 的全部系统版本.
最终为了完全适配 iOS 13.x, 我不得不弃用 SwiftUI 中的 NavigationView , 转而采用外部的 UINavigationController 设置 UIHostingControllerrootVC, UIHostingController 使用对应的 SwiftUI-View 来初始化的方式, 修复这些问题。