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