SwiftUI:在打开"工作表"的情况下重新打开应用程序时点击时出现奇怪的偏移



我正面临一种奇怪的行为,它看起来像一个 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")
})
}
}

要重现此问题,请执行以下步骤:

  1. 点击下方蓝色按钮的上边框Open sheet:工作表按预期打开。
  2. 工作表打开后,关闭应用程序(返回 Springboard,iOS 模拟器上的 cmd+shift+H)。
  3. 重新打开应用。您仍在工作表视图中。
  4. 合上工作表。您返回到带有蓝色按钮的主视图。这是错误:
  5. 再次点击蓝色按钮顶部,就在顶部边框下方。什么也没发生。您必须单击下面的几个像素。有一个偏移量使主视图上的所有可点击项目不对齐。

有人也见过这个错误吗?我做错了什么吗?

其他注意事项:

  • 从主视图关闭应用程序时,不会出现该错误。即使错误在这里并且我从主视图关闭应用程序并重新打开,该错误也会消失。
  • 如果我使用.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()

这是我当前的解决方法:

  1. 将一个(空白、隐藏的)TextField放在View的顶部(使用ZStack堆叠它们)。
  2. 检测发生此错误的唯一场景(scenePhase.background.active更改,显示.sheet,然后该工作表随后被消除)。
  3. 发生这种情况时聚焦TextField
  4. 然后在一瞬间(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 问题(我们有一个非常混合的代码库)。

相关内容

最新更新