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

SwiftUI自定义模态导航优化:实现类似fullScreenCover的按钮级调用

问题描述

有UIKit开发背景,正在SwiftUI中实现自定义底部弹窗(底部滑入式模态框),已完成基础功能,但希望优化复用性:无需为应用内每个页面添加ModalBottomViewModifier修饰符,而是像系统的fullScreenCover那样,直接将弹窗修饰符应用在触发按钮上。当前直接给按钮添加修饰符会导致弹窗显示在按钮旁而非全屏,需解决此问题。

解决方案

提供两种可行方案,分别适配纯SwiftUI和混合UIKit的场景:

方案一:纯SwiftUI全局弹窗容器 + 环境对象

通过全局状态管理+根视图弹窗容器,让弹窗始终显示在全屏层级,同时提供类似系统的修饰符接口。

1. 弹窗状态管理类

import SwiftUI

class ModalManager: ObservableObject {
    @Published var presentedModal: AnyView?
    
    func present<Content: View>(_ content: Content) {
        presentedModal = AnyView(content)
    }
    
    func dismiss() {
        presentedModal = nil
    }
}

如果需要保留原有的ModalType枚举,可将presentedModal改为ModalType?,并在present时传入对应枚举值

2. 根视图注入环境对象并添加弹窗容器

@main
struct YourApp: App {
    @StateObject private var modalManager = ModalManager()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(modalManager)
                // 全局弹窗容器,固定在底部
                .overlay(alignment: .bottom) {
                    if let modalView = modalManager.presentedModal {
                        modalView
                            .transition(.move(edge: .bottom))
                            .animation(.easeOut, value: modalManager.presentedModal)
                    }
                }
        }
    }
}

3. 自定义View扩展,提供系统风格的修饰符

extension View {
    // isPresented版本
    func bottomSheet<Content: View>(isPresented: Binding<Bool>, @ViewBuilder content: @escaping () -> Content) -> some View {
        self.modifier(BottomSheetPresentedModifier(isPresented: isPresented, content: content))
    }
    
    // item版本(适配枚举类型)
    func bottomSheet<Item: Equatable, Content: View>(item: Binding<Item?>, @ViewBuilder content: @escaping (Item) -> Content) -> some View {
        self.modifier(BottomSheetItemModifier(item: item, content: content))
    }
}

// 对应isPresented的修饰符实现
private struct BottomSheetPresentedModifier<Content: View>: ViewModifier {
    @Binding var isPresented: Bool
    @ViewBuilder let content: () -> Content
    @EnvironmentObject private var modalManager: ModalManager
    
    func body(content: Content) -> some View {
        content
            .onChange(of: isPresented) { newValue in
                if newValue {
                    modalManager.present(
                        content()
                            .environment(\.dismissModal, { isPresented = false })
                    )
                } else {
                    modalManager.dismiss()
                }
            }
    }
}

// 对应item的修饰符实现
private struct BottomSheetItemModifier<Item: Equatable, Content: View>: ViewModifier {
    @Binding var item: Item?
    @ViewBuilder let content: (Item) -> Content
    @EnvironmentObject private var modalManager: ModalManager
    
    func body(content: Content) -> some View {
        content
            .onChange(of: item) { newValue in
                if let item = newValue {
                    modalManager.present(
                        content(item)
                            .environment(\.dismissModal, { self.item = nil })
                    )
                } else {
                    modalManager.dismiss()
                }
            }
    }
}

// 保留原有的dismiss环境键
private struct ModalDismissKey: EnvironmentKey {
    static let defaultValue: () -> Void = {}
}

extension EnvironmentValues {
    var dismissModal: () -> Void {
        get { self[ModalDismissKey.self] }
        set { self[ModalDismissKey.self] = newValue }
    }
}

4. 使用方式(和系统fullScreenCover完全一致)

struct ContentView: View {
    @State var isPresented = false
    @State var presentedModal: ModalType?
    
    var body: some View {
        VStack {
            Button("Present modal", action: { isPresented = true })
                .foregroundColor(.black)
                .bottomSheet(isPresented: $isPresented) {
                    ModalView()
                }
            
            Button("Present via ModalType", action: { presentedModal = .normal })
                .foregroundColor(.black)
                .bottomSheet(item: $presentedModal) { $0.screen }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(.gray)
    }
}

方案二:基于UIKit模态呈现(完全模拟系统行为)

利用UIKit的模态视图控制器实现弹窗,确保弹窗层级独立于父视图,完全匹配系统fullScreenCover的行为逻辑。

1. UIKit底部弹窗控制器

import UIKit
import SwiftUI

class BottomSheetVC: UIViewController {
    private let contentView: UIView
    private let onDismiss: () -> Void
    
    init(content: UIView, onDismiss: @escaping () -> Void) {
        self.contentView = content
        self.onDismiss = onDismiss
        super.init(nibName: nil, bundle: nil)
        modalPresentationStyle = .overFullScreen
        modalTransitionStyle = .crossDissolve
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = UIColor.black.withAlphaComponent(0.5)
        
        // 添加内容视图并设置约束
        contentView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(contentView)
        
        NSLayoutConstraint.activate([
            contentView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            contentView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            contentView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            contentView.heightAnchor.constraint(equalToConstant: 300)
        ])
        
        // 滑入动画
        contentView.transform = CGAffineTransform(translationX: 0, y: contentView.frame.height)
        UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseOut) {
            self.contentView.transform = .identity
        }
    }
    
    // 滑出并关闭
    func dismissWithAnimation() {
        UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseIn) {
            self.contentView.transform = CGAffineTransform(translationX: 0, y: self.contentView.frame.height)
            self.view.backgroundColor = UIColor.black.withAlphaComponent(0)
        } completion: { _ in
            self.dismiss(animated: false, completion: self.onDismiss)
        }
    }
}

2. 包装成SwiftUI View

struct BottomSheetWrapper<Content: View>: UIViewControllerRepresentable {
    let content: Content
    let onDismiss: () -> Void
    
    func makeUIViewController(context: Context) -> BottomSheetVC {
        let hostingVC = UIHostingController(rootView: content.environment(\.dismissModal, onDismiss))
        return BottomSheetVC(content: hostingVC.view, onDismiss: onDismiss)
    }
    
    func updateUIViewController(_ uiViewController: BottomSheetVC, context: Context) {}
}

3. 自定义View扩展

extension View {
    func bottomSheet<Content: View>(isPresented: Binding<Bool>, @ViewBuilder content: @escaping () -> Content) -> some View {
        self.background(
            EmptyView()
                .sheet(isPresented: isPresented) {
                    BottomSheetWrapper(content: content(), onDismiss: { isPresented.wrappedValue = false })
                }
        )
    }
    
    func bottomSheet<Item: Equatable, Content: View>(item: Binding<Item?>, @ViewBuilder content: @escaping (Item) -> Content) -> some View {
        self.background(
            EmptyView()
                .sheet(item: item) { item in
                    BottomSheetWrapper(content: content(item), onDismiss: { item.wrappedValue = nil })
                }
        )
    }
}

4. 使用方式

和方案一完全一致,直接给按钮添加.bottomSheet修饰符即可。


方案对比

  • 方案一:纯SwiftUI实现,无需依赖UIKit,自定义动画和布局更灵活;但需全局管理弹窗状态,适合轻量级场景。
  • 方案二:完全复用UIKit模态系统,弹窗层级独立,行为和系统组件完全一致;适合需要复杂交互或完全匹配系统模态逻辑的场景。

内容的提问来源于stack exchange,提问作者Morpheus

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.25 10:18:33