10

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 content from parent has an offset on tap. It is difficult to explain (and English is not my mother tongue) 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 iOS 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 not aligned.

Does anyone have seen this bug also? Is there something I do wrong?

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.

EDIT:
I have tried two workarounds but both don't work:

  • Embed the Button in an external View.
  • Replace Button with only the Text and add .onTapGesture{ ... } modifier to toggle isOpen @State property.

EDIT 2:
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?

Jonathan
  • 606
  • 5
  • 19
  • Did you manage to fix it? – krlbsk Dec 01 '22 at 09:25
  • Unfortunately not. I have tried not of fixes but nothing worked. I've also open a bug report at Apple. – Jonathan Dec 02 '22 at 10:32
  • I also see this issue, and it also appears if using UIKit to present the sheet. eg. the `ContentView` is in a UIHostingController, and from this UIVC we `present` the `SheetContent` also in a UIHostingController, with the child hosting controller given `modalPresentationStyle = .pageSheet` – Loz Dec 06 '22 at 15:55
  • I'm also having this bug, but it occurs whether the user swipes down or presses a button to manually dismiss the sheet. So the suggestions don't fix... – yorkie1990 Apr 28 '23 at 09:23
  • See also [Touch layout not matching UI layout on iPhone after waking device when .sheet is open](https://developer.apple.com/forums/thread/724598?answerId=746253022#746253022) – Benzy Neez Jul 06 '23 at 13:07

3 Answers3

1

Here is my current workaround for this:

  1. Place a (blank, hidden) 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).

Result: 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've extracted as much of the code as possible into a separate View that can be reused.

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

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 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")
}
pxlshpr
  • 986
  • 6
  • 15
  • Great, thank you for such a cool workaround! The only thing I changed - instead of using NotificationCenter and logic behind it, just call ```.onDisappear { // show and hide keyboard logic here }``` on the sheet root view – Pavlo28 May 18 '23 at 12:34
0

I am facing the exact same issue. I've submitted a bug report to Apple. As a workaround, I'm manually adding a close button and preventing the user from swiping down to dismiss by using .interactiveDismissDisabled(). It's not ideal, but it's critical that the buttons on my main page work the way they should...

eric
  • 45
  • 7
0

In our experience we primarily get this problem in our UIKit VCs, especially when mixing with SwiftUI.

Here is a solution that works for your VCs, based on GeertB's answer here: https://developer.apple.com/forums/thread/724598?answerId=746253022#746253022

This is a one-shot fix that doesn't require modifying the modal style or attaching it to every VC - simply call the following startFixingSystemOffsetBug function in your AppDelegate's didFinishLaunching (so it is only called once).

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

Yes it contains swizzling but for solving this specific system issue I dont see a problem.

I have yet to find a generalised solution for SwiftUI, but so far it seems to be a UIKit problem on our side (we have a very mixed codebase).

Loz
  • 2,198
  • 2
  • 21
  • 22