You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.11 22:01:01