Post

Replies

Boosts

Views

Activity

Reply to Offset on tap items when reopening app with a sheet open
I too stumbled upon this a couple of months ago, and even resorted to begin restructuring my entire app's navigation paradigm to work around this (only using .fullScreenCovers etc). This soon became unfeasible, and thanks to a eureka moment I had after reading the second point mentioned by @bornbest: When a problem occurs, if you take out the keyboard, it will be restored to its original state. … this lead me to an unconventional approach to try and use this to devise a workaround: Placing a (blank, invisible) TextField on top of the View (using a ZStack to stack them). Detect the unique scenario where this bug occurs (scenePhase changing from .background → .active, with a .sheet being presented—and then that sheet subsequently being dismissed). Focus the TextField when this occurs Then a split second (0.01s) later, dismissing it (it's actually a bit more complicated than this, see the comments in the code for more about this). So the keyboard never gets shown to the user. But the desired effect of 'resetting' the view—namely the offset tap-targets being fixed—is achieved. I had an ingrained approach to this in the view I was having the issue with—but upon needing to do it on other views that also present sheets, I've extracted as much of the code as possible into a separate View that can be reused. I'd been meaning to share what I came up with in case it could be helpful to someone else until Apple fixes this on their end. Reusable Component 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 this is called after returning from the background /// (in which case `focusWhenVisible` would have been set) guard focusWhenVisible else { return } focusWhenVisible = false /// reset for next use /// Wait `0.2s` and then focus the `TextField` DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { isFocused = true /// Schedule multiple `isFocused = false` calls (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 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") } } How to use it The way I'm using this in the view that's presenting the sheet (and encountering the tap-target offset), is by doing something like: @State var presentedSheet: Sheet? = nil var body: some View { ZStack { tabView TapTargetResetLayer() } .onChange(of: presentedSheet, perform: presentedSheetChanged) .sheet(item: $presentedSheet) { sheet(for: $0) } } func presentedSheetChanged(_ newValue: Sheet?) { TapTargetResetLayer.presentedSheetChanged(toDismissed: newValue == nil) } The two main things I'm doing are: Placing the TapTargetResetLayer() in a ZStack with the rest of my content Calling the static TapTargetResetLayer.presentedSheetChanged(toDismissed:) whenever a sheet is presented or dismissed, which in turn sends a notification that instructs the TapTargetResetLayer to do the keyboard present-dismiss dance I mentioned above. Hope this helps!
Mar ’23