iosswiftui

SwiftUI sheet size becomes 0 when changing theme (iOS 16 bug?)


I'm stuck with a weird SwiftUI bug. I have a sheet that's supposed to size itself based on its content (using PreferenceKey + GeometryReader). Everything works fine until I try to change the user interface style using overrideUserInterfaceStyle.

When I change the theme like this, the sheet size suddenly becomes 0 and onPreferenceChange isn't even called. This only happens on iOS 16.

You can see the problem in the screen recordings.

iOS 16.4 iOS 18.1

import SwiftUI

fileprivate struct IntrinsicContentSizePreferenceKey: PreferenceKey {
    static let defaultValue: CGSize = .zero
    
    static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
        value = nextValue()
    }
}

fileprivate struct FittedPresentationDetentModifier: ViewModifier {
    @State private var contentSize: CGSize = .zero

    func body(content: Content) -> some View {
        content
            .overlay(
                GeometryReader { proxy in
                    Color.clear.preference(
                        key: IntrinsicContentSizePreferenceKey.self,
                        value: proxy.size
                    )
                }
            )
            .onPreferenceChange(IntrinsicContentSizePreferenceKey.self) { newSize in
                print("→ current size: \(contentSize)")
                print("→ new size: \(newSize)")
                guard newSize != .zero else { return }
                contentSize = newSize
            }
            .presentationDetents([.height(contentSize.height)])
            .presentationCornerRadius(32.0)
    }
}

fileprivate extension View {
    func fittedPresentationDetent() -> some View {
        modifier(FittedPresentationDetentModifier())
    }
}

extension View {
    func bottomSheet<Content: View>(
        isPresented: Binding<Bool>,
        @ViewBuilder content: @escaping () -> Content
    ) -> some View {
        self.sheet(isPresented: isPresented) {
            content()
                .fittedPresentationDetent()
        }
    }
}

i created a sample project for testing.

import SwiftUI

struct ContentView: View {
    @State private var showSheet = false
    @Environment(\.colorScheme) var colorScheme
    
    var isDarkMode: Bool {
        colorScheme == .dark
    }
    
    var body: some View {
        NavigationView {
            VStack(spacing: 20) {
                Image(systemName: isDarkMode ? "moon.fill" : "sun.max.fill")
                    .font(.system(size: 60))
                    .foregroundColor(isDarkMode ? .white : .yellow)
                    .padding()
                
                Text("Current Theme: \(isDarkMode ? "Dark" : "Light")")
                    .font(.headline)
                
                Button(action: {
                    showSheet.toggle()
                }) {
                    Text("Change Theme")
                        .font(.headline)
                        .padding()
                        .background(Color.blue)
                        .foregroundColor(.white)
                        .cornerRadius(10)
                }
            }
            .navigationTitle("Theme Switcher")
            .bottomSheet(isPresented: $showSheet, content: {
                ThemeSettingsSheet(isDarkMode: isDarkMode)
            })
        }
    }
}

struct ThemeSettingsSheet: View {
    let isDarkMode: Bool
    @Environment(\.dismiss) var dismiss
    
    var body: some View {
        Toggle(isOn: Binding(
            get: { isDarkMode },
            set: { newValue in
                changeTheme(isDarkMode: newValue)
            }
        )) {
            HStack {
                Image(systemName: isDarkMode ? "moon.fill" : "sun.max.fill")
                    .foregroundColor(isDarkMode ? .white : .yellow)
                Text("Dark Mode")
            }
        }
        .padding()
    }
    
    private func changeTheme(isDarkMode: Bool) {
        guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
              let window = windowScene.windows.first else { return }
        
        UIView.transition(with: window,
                          duration: 0.3,
                          options: .transitionCrossDissolve,
                          animations: {
            window.overrideUserInterfaceStyle = isDarkMode ? .dark : .light
        }, completion: nil)
    }
}

#Preview {
    ContentView()
}

Anyone run into this? Any workarounds? Thanks!


Solution

  • This happens because you are using UIKit APIs to change the color scheme. The UIKit code affected the layout of the sheet in some way that SwiftUI is not aware of. As far as SwiftUI is concerned, the height of the sheet is still the same, but from UIKit's side, its height is different.

    If you can change the color scheme using SwiftUI-native ways (see some examples here), you should do that.


    To fix this, you need to work on the UIKit's side. By adding in your own UIViewController into the sheet's hierarchy, you can detect when the sheet has been incorrectly resized by comparing the parent VC's height (controlled by UIKit code) with the height of your own VC (controlled by your SwiftUI code).

    struct Helper: UIViewControllerRepresentable {
        class HelperVC: UIViewController {
            override func viewDidLayoutSubviews() {
                super.viewDidLayoutSubviews()
                // this means the height of the sheet is incorrect
                if let parentHeight = parent?.view.bounds.height, parentHeight < view.bounds.height {
                    guard let sheet = parent?.sheetPresentationController else { return }
    
                    // here, sheet.detents will contain a detent with an incorrect height,
                    // so I set the detents back to what it should be
                    sheet.detents = [UISheetPresentationController.Detent.custom { [height = view.bounds.height] _ in
                        height
                    }]
                }
            }
        }
        
        func makeUIViewController(context: Context) -> HelperVC { .init() }
        
        func updateUIViewController(_ uiViewController: HelperVC, context: Context) { }
    }
    
    fileprivate struct FittedPresentationDetentModifier: ViewModifier {
        @State private var contentSize: CGSize = .zero
    
        func body(content: Content) -> some View {
            content
                // you can just add this as a background 
                // consider adding a if #unavailable(...) { ... } so this is only added on iOS 16
                .background { Helper() }
                // instead of a GeometryReader, using onGeometryChanged is much cleaner
                .onGeometryChange(for: CGSize.self, of: \.size) { newSize in
                    guard newSize != .zero else { return }
                    contentSize = newSize
                }
                .presentationDetents([.height(contentSize.height)])
                .presentationCornerRadius(32.0)
        }
    }