iOS 16.4 SwiftUI Bottom Sheet偶发忽略presentationDetents的问题咨询
SwiftUI 底部弹窗切换时忽略presentation修饰符的Bug及绕过方案
从iOS 16.4开始,在SwiftUI中,我们可以结合.sheet修饰符与.presentationDetents指定底部弹窗(Bottom Sheet)的高度,还能通过.presentationBackgroundInteraction允许与弹窗后方视图交互。
我遇到一个疑似SwiftUI的Bug,可将以下代码粘贴到Playground中复现该问题(这是实际应用中问题的简化版本):
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))
Bug现象:有时在切换选中项时,弹窗会完全忽略.presentationDetents和.presentationBackgroundInteraction修饰符,变为全屏模态弹窗且无法与背景交互。
状态说明
| 无弹窗 | 正常弹窗 | 异常弹窗 |
|---|---|---|
| 页面仅显示分段选择器 | 底部弹出中等高度的弹窗,可与背景交互 | 弹窗变为全屏模态,无法与背景交互 |
观察结果
- 先点击“None”隐藏弹窗,再选择其他选项,绝不会触发该Bug;
- 直接从一个选项切换到另一个选项(如从“One”切到“Two”),有时会触发Bug;
- 实际应用未使用
PickerView,因此问题与Picker无关; - 推测弹窗忽略
presentationBackgroundInteraction是因为其高度超出.medium,变为全屏。
该问题源于弹窗选中项未经过nil状态就直接切换,而这是合理需求(不希望弹窗先消失再重新出现)。以下是不引入第三方库的绕过方案:
绕过方案1:通过onChange监听并手动重置状态
核心思路是当选中项从非nil值切换到另一个非nil值时,先将状态置为nil销毁弹窗,短暂延迟后再设置新值重建弹窗,避免SwiftUI内部状态异常。
修改后的代码:
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() .onChange(of: selection) { newValue in guard let newValue, selection != nil else { return } // 先清空状态销毁弹窗 selection = nil // 延迟0.1秒后设置新值重建弹窗 DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { selection = newValue } } 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))
绕过方案2:用自定义Binding封装状态切换逻辑
将状态重置逻辑封装在自定义Binding中,视图代码更简洁,便于复用:
import SwiftUI import PlaygroundSupport struct ContentView: View { let choices = [nil, "One", "Two", "Three"] @State private var internalSelection: String? // 自定义Binding处理状态切换 private var selection: Binding<String?> { Binding( get: { internalSelection }, set: { newValue in guard let newValue, internalSelection != nil else { internalSelection = newValue return } // 先清空状态 internalSelection = nil // 延迟重建弹窗 DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { internalSelection = newValue } } ) } 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: $internalSelection) { 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))
方案说明
两种方案本质都是通过“先销毁再重建”弹窗的方式,避免SwiftUI在直接切换弹窗关联数据时出现的状态异常。0.1秒的延迟足够让弹窗完成销毁流程,可根据实际动画效果微调时长。
内容的提问来源于stack exchange,提问作者norders
相关产品推荐
相关产品推荐

