/ swiftui

超谈 SwiftUI:.sheet() 坑多多

前言

此文为「超谈 SwiftUI」系列的文章,主要输出一些使用 SwiftUI 开发几个 apps 后的一些心得、经验和踩坑记录。希望能帮助到你。

本文将介绍 SwiftUI 里 .sheet() 在特定场景下的 Bug 以及 Workaround。

什么是 .sheet()

在 SwiftUI 上,你使用 .sheet() 来展示一个 Sheet 页面,并在里面展示你的 SwiftUI View。

HIG 上关于 Sheet 的描述如下:

A sheet helps people perform a scoped task that’s closely related to their current context.
  • 在 iOS 上,将会以一种近似全屏的方式呈现一个新页面。默认情况下,底部的页面将会使用平移和缩放的方式,“退” 到背后,给人一种视觉上两个页面堆叠的效果。在没有额外设置和手势冲突的情况下,用户可以通过往下拖拽新页面来关闭。在 iOS 16.0 上,还能设置 PresentationDetent 来改变 Sheet 动态显示的高度——也就是,可以设置为在开始的时候 Sheet 只显示特定的高度,然后用户可以通过手动拖拽的方式来让其显示指定的最大高度。
  • 在 iPadOS 上,如果所在的 horizontalSizeClass 为 Compact,那么效果将跟 iOS 一致;如果为 Regular,那么效果将跟 macOS 效果一致。
  • 在 macOS 上,将会在中间呈现一个 Modal 页面,此页面没有提供关闭按钮,开发者需要提供显式的关闭按钮以触发页面消失。

SwiftUI 的 .sheet() 有两个重载方法:

sheet(isPresented:onDismiss:content:)

以及

sheet(item:onDismiss:content:)

在背后所做的事情都是一样的:观察特定条件,来显示一个 Sheet 页面。在 iOS 上,等同于使用 UISheetPresentationController ;在 macOS 上,等同于使用 presentAsSheet(_:)

.sheet() 坑:特定条件下导致顶部无法触发触控事件

.sheet() 使用简单,使用起来很难有什么特别坑的地方——当然有的话,也意味着将很难彻底解决。

前面提到两点:

  • 默认情况下,在 Sheet 出现的时候,底部的页面将会使用平移和缩放的方式,“退” 到背后,给人一种视觉上两个页面堆叠的效果。
  • 在 iOS 16.0 上,可以通过设置 PresentationDetent 来改变 Sheet 动态显示的高度。也就是,可以设置为在开始的时候 Sheet 只显示特定的高度,然后用户可以通过手动拖拽的方式来让其显示指定的最大高度。

基于以上背景,.sheet() 在 iOS 16.0 系统或以上,在以下操作下会出现问题:

  • 使用 SwiftUI 来编写一个页面,然后使用 .sheet() 来显示一个 Sheet,不改变 PresentationDetents 或者使用 presentationDetents(_:) 设置为 Large
  • 该页面,有使用 ScrollView 或者其他 View,但是使用了 .ignoresSafeArea()
  • 调用 .sheet() 的页面,在顶部靠近 Status Bar 的位置放了一些按钮(或其他使用 onTapGesture 触发点击行为的 View)
  • 在 Sheet 被打开的情况下,让 App 处于 Background 状态:切换到其他 apps 或者回到 Home 都可以。然后再回到你的 app。
  • 关闭 Sheet,尝试点击顶部的按钮。
  • 会发现触控不生效,需要重启 app (或者再一次在当前页面退到桌面再回来)才能恢复正常——实质上,由于底部页面整体进行了往下平移,你需要往下方的位置点击才能触发原本的点击行为。
  • 如果你使用了 NavigationView 并在 Toolbar 放置了一些按钮,那么 Toolbar 上的按钮不受影响。

代码如下:

struct ContentView: View {
    @State private var showSheet = false
    
    var body: some View {
        VStack {
            Button {
                showSheet = true
                print("on tap button")
            } label: {
                Text("Tap me to present a sheet")
            }
            ScrollView {
                LazyVStack {
                    ForEach(0...20, id: \.self) { item in
                        Text(String(item)).padding()
                    }
                }
            }
        }
        .sheet(isPresented: $showSheet) {
            Text("Sheet content")
        }
    }
}

上述能触发问题的代码重点是:

  • 使用了 ScrollView,此 View 内部会使用 .ignoreSafeArea(),是触发上述问题的必要条件;因此,如果把 ScrollView 换成以下代码,也是能触发问题的:
Text("Ignored")
	.ignoresSafeArea()
	.frame(maxHeight: .infinity)

问题原因推测

前面提到,在 Sheet 出现的时候,底部的页面将会使用平移和缩放的方式,“退” 到背后,给人一种视觉上两个页面堆叠的效果。通过 Xcode 来查看布局,也能证实这点。下图为发生问题的时候(也就是顶部按钮无法点击),通过查看 View Hierarchy 里抓取的页面布局:

  • UIWindow 的根 View 之一是一个 UITransitionView,是一个 Apple 内部的 UIView,它属于 UIPresentationController 的一部分,用于在 Present 的时候去操控 View 的布局变换。展示 Sheet 时候对底部页面的 Transform,是由此 UITransitionView 来操作。在右侧 Object Inspector 里,通过它的直接子 UIView UIDropShadowView 的 Frame 和 Transform 看到这点。
  • 在 View Hierarchy 里看 HostingView 下的 ContentView 的 outline 信息,它的位置和大小都显然不对——尽管从视觉上看,它绘制的图像在屏幕里是正确显示了,这可能仅仅是因为 CALayer 的布局信息没问题,但是处理触控事件的 UIView 内部产生 youwent 的属性,这就导致了最后的触控事件无法正常触发。

Workaround

在知道问题的背景和经过初步分析后,在经过尝试后,发现可以通过这样的方式解决,在 Sheet 消失的时候,执行一下以下代码:

private func fixContentViewTransformIssue() {
    UIApplication.shared.windows.forEach { window in
        guard let view = window.rootViewController?.view else {
            return
        }
        
        view.transform = CGAffineTransform(translationX: 0, y: 1)
        view.transform = CGAffineTransform.identity
    }

}

重点在于,把根 UIViewController 的 View,设置两次 transform 属性,第一次必须改变 y 的值,第二次必须设置为 identity。尽管我们都知道,设置两次 transform 不会在绘制上产生什么实际的差别,最终会在下一次绘制 Cycle 里进行重新布局和绘制。所以这里的 y 值设置多少都没问题,只要不是 0 即可,在视觉效果上,我们不会看到此 View 会有任何平移。

但通过这样的设置,测试能解决问题。

上述的代码,可以在 Sheet 的 onDismiss 里去调用,为了能让使用方方便调用,我们可以包装一个 .sheetCompat()

public extension View {
    func sheetCompat<ViewContent: View, Item: Identifiable>(
        item: Binding<Item?>,
        onDismiss: (() -> Void)? = nil,
        @ViewBuilder content: @escaping (Item) -> ViewContent
    ) -> some View {
        self.sheet(item: item, onDismiss: {
            onDismiss?()
            fixContentViewTransformIssue()
        }, content: content)
    }
    
    func sheetCompat<ViewContent: View>(
        isPresented: Binding<Bool>,
        onDismiss: (() -> Void)? = nil,
        @ViewBuilder content: @escaping () -> ViewContent
    ) -> some View {
        self.sheet(isPresented: isPresented, onDismiss: {
            onDismiss?()
            fixContentViewTransformIssue()
        }, content: content)
    }
}

private func fixContentViewTransformIssue() {
#if os(iOS)
    UIApplication.shared.windows.forEach { window in
        guard let view = window.rootViewController?.view else {
            return
        }
        view.transform = CGAffineTransform(translationX: 0, y: 100)
        view.transform = CGAffineTransform(translationX: 0, y: 0)
    }
#endif
}

因为 SwiftUI 的 .sheet() 有两个重载方法,所以针对 .sheet(item:onDismiss:content).sheet(isPresented:onDismiss:content) 都需要分别做处理。

使用方式也很简单,把原来使用 .sheet() 的都改为使用 .sheetCompat() 即可:

struct ContentView: View {
    @State private var showSheet = false
    
    var body: some View {
        VStack {
            // content...
        }
        .padding()
        .sheetCompat(isPresented: $showSheet) {
            Text("Sheet content")
        }
    }
}

结论

以上介绍了 .sheet() 这个 ViewModifier 在 iOS 16.0 或以上,在特定条件下会触发的问题以及解决方案。

尽管触发条件看似有点苛刻,但在用户的实际使用场景下,我认为触发的概率是挺高的——在第一次遇到后,我也以为是一个很低概率的 Bug,但就在自己开发的过程中遇到的次数也越来越多,所以终于去尝试分析并解决此问题,希望能帮到你。

当然,.sheet() 的坑不止一个,在写本文的时候,已经遇到了 .sheet(item:onDismiss:content) 的另一个更可能会导致 Crash 的坑了。这个 Crash 信息为:

Thread 1: "Application tried to present modally a view controller <TtGC7SwiftUI29PresentationHostingControllerVS_7AnyView: 0x1682a6c00> that is already being presented by <TtGC7SwiftUI19UIHostingControllerGVS_15ModifiedContentVS_7AnyViewVS_12RootModifier_: 0x15c843a00>."

至于这个具体是什么原因,我们留到下回再说。