iosswiftuibottom-sheet

iOS 16.4, SwiftUI bottom sheet is sometimes ignoring presentationDetents


From iOS 16.4 in SwiftUI we can use the .sheet modifier with .presentationDetents to specify the height of a bottom sheet. We can further use .presentationBackgroundInteraction to allow interaction with the view behind the sheet.

I'm encountering a problem that looks to me like a SwiftUI bug.

You can paste this code into a playground to see the buggy behaviour in action (this is a simplified version of my actual app that has the issue).

The bug is that sometimes when changing from one selection to another, the sheet completely ignores the .presentationDetents and .presentationBackgroundInteraction modifiers. It just shows as a full height modal without any background interaction.

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    let choices = [nil, "One", "Two", "Three"]
    @State var selection: String?
    
    var body: some View {
        VStack {
            Picker("Choose", selection: $selection) {
                ForEach(choices, id: \.self) { choice in
                    Text(choice ?? "None")
                        .tag(choice)
                }
            }
            .padding()
            
            Spacer()
        }
        .pickerStyle(.segmented)
        .sheet(item: $selection) { choice in
            Text("Item selected: \(choice)")
                .presentationDetents([.medium])
                .presentationBackgroundInteraction(
                    .enabled(upThrough: .medium)
                )
        }
    }
}

extension String: Identifiable {
    public var id: String { self }
}

PlaygroundPage.current.setLiveView(ContentView().frame(width: 320, height: 568))

Here are some screenshots:

No modal Correct modal Buggy modal
enter image description here enter image description here enter image description here

Observations

  1. Hiding the sheet (by tapping 'None') before selecting another choice never exhibits the bug.
  2. Jumping from one selection to another sometimes (but not always) exhibits the bug, e.g. jumping directly from 'One' to 'Two'.
  3. My actual app is not using a PickerView, so it's not to do with that.
  4. I'm assuming it's ignoring presentationBackgroundInteraction because it has gone way past .medium and up to full height.

So it seems to be an issue with a sheet's selection not passing through the nil state before changing to another selection, which to me is a perfectly valid thing to want to do (I don't want the sheet disappearing then re-appearing).

Assuming this is an actual bug, any ideas how can I work around it? I don't really want to write or import a third party library for something that should be working natively.

Thanks in advance for any insights!


Solution

  • I don't think you can use sheet(item:) if you don't want the sheet disappearing and reappearing. Even if the presentationDetents bug is fixed, I think the intended behaviour of sheet(item:) is still going to be "dismiss the sheet then present another one" when the item changes.

    Use sheet(isPresented:) instead. Show the sheet as long as the selection is not nil.

    .sheet(isPresented: isNotNil($selection)) {
        if let selection {
            Text("Item selected: \(selection)")
                .presentationDetents([.medium])
                .presentationBackgroundInteraction(.enabled(upThrough: .medium))
        }
    }
    
    func isNotNil<T>(_ binding: Binding<T?>) -> Binding<Bool> {
        Binding {
            binding.wrappedValue != nil
        } set: { notNil in
            if !notNil {
                binding.wrappedValue = nil
            }
        }
    }
    

    If you don't mind the sheet disappearing and appearing again, you can add a separate @State for presenting sheet. Set that state to an intermediary nil value when selection changes.

    @State var sheetItem: String?
    
    .onChange(of: selection) { x in
        sheetItem = nil
        DispatchQueue.main.async {
            sheetItem = x
        }
    }
    .sheet(item: $selection) { choice in ... }