Offset on tap items when reopening app with a sheet open

I am facing out a strange behavior that really looks like a SwiftUI bug.

When I leave the app with a .sheet open and reopen it, all tappable content from parent view (such as Buttons) have an offset on tap. It is difficult to explain so here is a really simple example:

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")
        })
    }
}

To reproduce the issue follow those steps:

  1. Tap just below to the top border of blue button Open sheet: the sheet opens as expected.
  2. When the sheet is open, close the app (go back to Springboard, cmd+shift+H on Simulator).
  3. Reopen the app. You're still on the sheet view.
  4. Close the sheet. You're back on main view with blue button. Here is the bug:
  5. Tap again on the top of blue button, right below the top border. Nothing happens. You have to click few pixels below. There is an offset that makes all tappable items on main view (and children) not aligned.

Other notices:

  • When closing the app from main view, the bug doesn't appear. And even when the bug is here and I close the app from main view and reopen, the bug disappears.
  • If I use a .fullScreenCover instead of .sheet, the bug doesn't appear.
  • It really looks like a bug with .sheets open.

Expected result would be that this offset does not appear and Buttons are still tappable on their area.

Tested on Xcode 14.0 (14A309), iOS 16.0.x (Simulator and iPhone 14 Pro device) and Swift 5.

After hours of tries I could find something interesting: if, in the sheet content, I add a button to dismiss the sheet, the bug doesn't appear anymore. But if I dismiss the sheet with finger (drag from top to bottom), it still appears.Here is modified code:

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")
        })
    }
}

It looks like there is something with calling (or not) the @Environment(\.dismiss) var dismiss.

The current state is a bit better as few days ago as the bug only appears when user dismiss the sheet by dragging down. But there is still something wrong.

Is there a way to programmatically call dismiss() when sheet is closed by dragging down?

Hello I also found the same problem. Have you not found a solution yet?

Our app is about to be released, but it seems to be a critical problem.

Here's what I found after doing a lot of testing:

  1. There is no problem with half sheet or full cover sheet
  2. When a problem occurs, if you take out the keyboard, it will be restored to its original state.

Also seeing this in iOS 16.2, worked around by using .fullScreenCover but that's not ideal...

Filing a Feedback...

Same issue here :( I have a UIHostingController in an UINavigationController and when I present a screen as a sheet over it, then if I e.g. tap the power button to go to the background and then unlock the phone, then dismiss the modal by dragging the sheet down, the appearance is fine but in view hierarchy everything's offset and the taps are as well. Found only in iOS 16.x

Same issue as pawelw mentions. UIHostingController in a UINavigationController. Every time the app is restored from the background tap area is working with vertical offset. Once I open a new tab in the tab menu, the issue disappears. Seems like a bug of iOS 16.x

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:

  1. Placing a (blank, invisible) TextField on top of the View (using a ZStack to stack them).
  2. 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).
  3. Focus the TextField when this occurs
  4. 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:

  1. Placing the TapTargetResetLayer() in a ZStack with the rest of my content
  2. 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!

As my observing, it seems that the view which is covered by a sheet is "re-measured then cached" after awakening from the background state. And because it is covered by the sheet, its size shrinks so all of its subviews are in a wrong sized container.

I've tried to set a @State var inside the covered view to trigger another re-creating, but it's not working. The re-measured size seems being cached somewhere for performance. As the other posts said, keyboard may trigger the size re-calculation, and I also found that when the bug occurred, without showing a sheet, press the side button(power button) to put the app into the background then resume it, the view could be re-measured into the correct size again. Also, the bug could be only triggered by resuming the app from the BACKGROUND state, INACTIVE state(press side button twice in a short period) does not trigger the bug.

I think the view should be taken snapshot for both "normal state" and "shrunk state" after awakening from the background. But anyway, this bug is really annoying :(

Same issue here. A simple workaround could be to dismiss the sheet when the app goes into the background:

.onChange(of: scenePhase) { scenePhase in
    if scenePhase == .background {
        isPresentingSheet = false
    }
}

This problem is described in another forum post, along with another good workaround: https://developer.apple.com/forums/thread/724598?answerId=746253022#746253022

sad situation. The product team wants the page to be opened as a sheet instead of a full screen cover, but we faced a lot of problems. We solved it by giving navigationview .id() but in this case, since the scroll in the nagitaion view was redrawn, it moved the page from the bottom to the top.

@Benzy_Neez thank you for the great solution. It fixed our issue.

Offset on tap items when reopening app with a sheet open
 
 
Q