超谈 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 里,通过它的直接子 UIViewUIDropShadowView
的 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>."
至于这个具体是什么原因,我们留到下回再说。
Subscribe to JuniperPhoton's Blog
Get the latest posts delivered right to your inbox