我正面临一种奇怪的行为,它看起来像一个 SwiftUI 错误。
当我打开应用程序并重新打开.sheet
时,父级的所有内容在点击时都有偏移量。这很难解释(英语不是我的母语),所以这里有一个非常简单的例子:
struct ContentView: View {
@State private var isOpen = false
var body: some View {
Button(action: {
isOpen.toggle()
}, label: {
Text("Open sheet")
.foregroundColor(.white)
.padding()
.background(.blue)
})
.sheet(isPresented: $isOpen, content: {
Text("Sheet content")
})
}
}
要重现此问题,请执行以下步骤:
- 点击下方蓝色按钮的上边框
Open sheet
:工作表按预期打开。 - 工作表打开后,关闭应用程序(返回 Springboard,iOS 模拟器上的 cmd+shift+H)。
- 重新打开应用。您仍在工作表视图中。
- 合上工作表。您返回到带有蓝色按钮的主视图。这是错误:
- 再次点击蓝色按钮顶部,就在顶部边框下方。什么也没发生。您必须单击下面的几个像素。有一个偏移量使主视图上的所有可点击项目不对齐。
有人也见过这个错误吗?我做错了什么吗?
其他注意事项:
- 从主视图关闭应用程序时,不会出现该错误。即使错误在这里并且我从主视图关闭应用程序并重新打开,该错误也会消失。
- 如果我使用
.fullScreenCover
而不是.sheet
,则不会出现该错误。 - 它看起来真的像一个打开
.sheets
的错误。
编辑:
我已经尝试了两种解决方法,但都不起作用:
- 将
Button
嵌入到外部视图中。 - 仅将
Button
替换为Text
.onTapGesture{ ... }
并添加修饰符以切换isOpen
@State属性。
编辑 2:
经过数小时的尝试,我可以找到一些有趣的东西:如果在工作表内容中添加一个按钮来关闭工作表,则该错误不再出现。但是,如果我用手指关闭工作表(从上到下拖动),它仍然会出现。
这是修改后的代码:
struct ContentView: View {
@State private var isOpen = false
var body: some View {
Button(action: {
isOpen.toggle()
}, label: {
Text("Open sheet")
.foregroundColor(.white)
.padding()
.background(.blue)
})
.sheet(isPresented: $isOpen, content: {
SheetContent()
})
}
}
struct SheetContent: View {
@Environment(.dismiss) var dismiss
var body: some View {
Button(action: { dismiss() }, label: {
Text("Dismiss sheet")
})
}
}
看起来打电话(或不打电话)@Environment(.dismiss) var dismiss
有些东西.
当前状态比几天前好一些,因为该错误仅在用户通过向下拖动关闭工作表时才出现。但还是有些不对劲。
有没有办法在通过向下拖动关闭工作表时以编程方式调用dismiss()
?
这是我当前的解决方法:
- 将一个(空白、隐藏的)
TextField
放在View
的顶部(使用ZStack
堆叠它们)。 - 检测发生此错误的唯一场景(
scenePhase
从.background
→.active
更改,显示.sheet
,然后该工作表随后被消除)。 - 发生这种情况时聚焦
TextField
- 然后在一瞬间(
0.01s
)之后,将其关闭(它实际上比这要复杂一些,请参阅代码中的注释以获取有关此的更多信息)。
结果:键盘永远不会向用户显示。但是,"重置"视图的预期效果(即固定偏移抽头目标)已经实现。
我已经将尽可能多的代码提取到可以重用的单独View
中。
可重复使用的组件
import SwiftUI
public struct TapTargetResetLayer: View {
@Environment(.scenePhase) var scenePhase
@State var wasInBackground: Bool = false
@State var focusWhenVisible = false
@State var isPresentingSheet: Bool = false
@FocusState var isFocused: Bool
let sheetWasDismissed = NotificationCenter.default.publisher(for: .tapTargetResetSheetWasDismissed)
let sheetWasPresented = NotificationCenter.default.publisher(for: .tapTargetResetSheetWasPresented)
public init() { }
public var body: some View {
textField
.onChange(of: scenePhase, perform: scenePhaseChanged)
.onReceive(sheetWasDismissed, perform: sheetWasDismissed)
.onReceive(sheetWasPresented, perform: sheetWasPresented)
}
var textField: some View {
TextField("", text: .constant(""))
.focused($isFocused)
.opacity(0)
}
func scenePhaseChanged(to newPhase: ScenePhase) {
switch newPhase {
case .background:
wasInBackground = true
case .active:
/// If we came from the background and are currently presenting a sheet
if wasInBackground, isPresentingSheet {
/// Set this so that the `TextField` gets focused (and immediately dismissed) once the sheet is dismissed
focusWhenVisible = true
wasInBackground = false /// reset for next use
}
default:
break
}
}
func sheetWasPresented(_ notification: Notification) {
isPresentingSheet = true
}
func sheetWasDismissed(_ notification: Notification) {
isPresentingSheet = false /// reset for next use
/// Only continue if we this is called after returning from background
/// (in which case `focusWhenVisible` would have been set)
guard focusWhenVisible else { return }
focusWhenVisible = false /// reset for next use
/// This sequence of events first waits `0.2s` and then focuses the `TextField`
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
isFocused = true
/// We then queue up `isFocused = false` calls multiple times (every `0.01s` for the next `2s`)
/// to ensure that:
/// - The keyboard gets dismissed as soon as possible.
/// - The keyboard **definitely** does get dismissed (it's not guaranteed which call actually dismisses it,
/// so I've found that making these multiple calls in quick succession is critical to ensure its dismissal).
///
/// *Note: There are rare instances where you see a quick glimpse of the keyboard being dismissed, but
/// because:
/// a) this bug is not a common occurrence for the user to begin with, and
/// b) the chance of the keyboard dismissal actually being viewed is even less likely,
/// I've decided its a much more worthy tradeoff than essentially having a broken UI until the view is implicitly
/// refreshed by some other implicit means.*
let delays = stride(from: 0.0, through: 2.0, by: 0.01)
for delay in delays {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
isFocused = false
}
}
}
}
}
extension TapTargetResetLayer {
public static func presentedSheetChanged(toDismissed: Bool) {
NotificationCenter.default.post(
name: toDismissed
? .tapTargetResetSheetWasDismissed
: .tapTargetResetSheetWasPresented,
object: nil
)
}
}
public extension Notification.Name {
static var tapTargetResetSheetWasDismissed: Notification.Name { return .init("tapTargetResetSheetWasDismissed") }
static var tapTargetResetSheetWasPresented: Notification.Name { return .init("tapTargetResetSheetWasPresented") }
}
如何使用它
我在呈现工作表(并遇到点击目标偏移)的视图中使用它的方式是执行以下操作:
@State var showingSheet: Bool = false
var body: some View {
ZStack {
tabView
/// Place the TapTargetResetLayer() in a ZStack with the original content
TapTargetResetLayer()
}
.onChange(of: showingSheet, perform: showingSheetChanged)
.sheet(presented: $showingSheet) { someSheet }
}
/// This is called whenever a sheet is presented or dismissed,
/// which in turn sends a notification that instructs the
/// TapTargetResetLayer to do the keyboard present-dismiss toggle
/// to essentially 'reset' the view and remove the offset.
func showingSheetChanged(_ newValue: Bool) {
TapTargetResetLayer.presentedSheetChanged(toDismissed: newValue == false)
}
var someSheet: some View {
Text("The sheet causing the issue")
}
我面临着完全相同的问题。我已经向苹果公司提交了错误报告。作为一种解决方法,我手动添加一个关闭按钮,并防止用户使用 .interactiveDismissDisabled() 向下滑动以关闭。这并不理想,但至关重要的是,我的主页上的按钮必须以应有的方式工作......
根据我们的经验,我们主要在 UIKit VC 中遇到这个问题,尤其是在与 SwiftUI 混合时。
以下是适用于您的VC的解决方案,基于GeertB在此处的答案:https://developer.apple.com/forums/thread/724598?answerId=746253022#746253022
这是一个一次性修复,不需要修改模式样式或将其附加到每个 VC - 只需在 AppDelegate 的didFinishLaunching
中调用以下startFixingSystemOffsetBug
函数(因此仅调用一次)。
func startFixingSystemOffsetBug() {
swizzle(#selector(UIViewController.viewDidDisappear(_:))) { _ in
let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene
if let viewFrame = scene?.windows.first?.rootViewController?.view.frame {
scene?.windows.first?.rootViewController?.view.frame = .zero
scene?.windows.first?.rootViewController?.view.frame = viewFrame
}
}
}
private func swizzle(_ selector: Selector, implementation: @escaping (UIViewController) -> Void) {
typealias TypeIMP = @convention(c)(UIViewController, Selector) -> Void
let instanceMethod = class_getInstanceMethod(UIViewController.self, selector)
assert(instanceMethod != nil, "UIViewController should implement (selector)")
var originalIMP: IMP? = nil
let swizzledIMPBlock: @convention(block) (UIViewController) -> Void = { (receiver) in
// Invoke the original IMP if it exists
if originalIMP != nil {
let imp = unsafeBitCast(originalIMP, to: TypeIMP.self)
imp(receiver, selector)
}
implementation(receiver)
}
let swizzledIMP = imp_implementationWithBlock(unsafeBitCast(swizzledIMPBlock, to: AnyObject.self))
originalIMP = method_setImplementation(instanceMethod!, swizzledIMP)
}
是的,它包含旋转,但为了解决这个特定的系统问题,我没有看到问题。
我还没有找到 SwiftUI 的通用解决方案,但到目前为止,这似乎是我们这边的 UIKit 问题(我们有一个非常混合的代码库)。