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:
> 2. 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.
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") }
}
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!