无需CocoaPods:如何在Root ViewController中以Bottom Sheet展示多ViewController
实现自定义Bottom Sheet ViewController(无需CocoaPods)
嘿,刚好我最近做过类似的需求,不用第三方库完全可以自己实现自定义的Bottom Sheet,下面是具体的思路和代码示例,一步步来就能搞定:
核心思路
- 用一个容器ViewController作为Bottom Sheet的载体,把要展示的目标VC作为子控制器嵌入进去,这样就能灵活切换不同的内容
- 通过约束动画控制容器视图的位置,初始让它藏在屏幕底部,点击按钮时滑入屏幕;关闭时滑出
- 添加手势交互,支持拖拽关闭和点击背景关闭,提升用户体验
- 在Root VC中管理这个容器的复用,避免重复创建,切换内容时直接替换子VC就行
步骤1:创建Bottom Sheet容器类
先写一个专门的容器类BottomSheetContainerViewController,负责处理动画、手势和内容VC的管理:
import UIKit class BottomSheetContainerViewController: UIViewController { // 用来承载内容VC的容器视图 private let contentContainer = UIView() // 控制Sheet位置的核心约束(初始在屏幕外) private var bottomConstraint: NSLayoutConstraint! // 当前显示的内容VC private var currentContentVC: UIViewController? override func viewDidLoad() { super.viewDidLoad() setupBaseUI() setupInteractionGestures() } private func setupBaseUI() { // 半透明背景 view.backgroundColor = UIColor.black.withAlphaComponent(0.5) view.isUserInteractionEnabled = true // 配置内容容器 view.addSubview(contentContainer) contentContainer.translatesAutoresizingMaskIntoConstraints = false contentContainer.backgroundColor = .white contentContainer.layer.cornerRadius = 16 // 顶部圆角 contentContainer.clipsToBounds = true // 设置内容容器的约束:左右贴边,高度默认占屏幕60% NSLayoutConstraint.activate([ contentContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor), contentContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor), contentContainer.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: 0.6) ]) // 初始时让内容容器完全在屏幕下方 bottomConstraint = contentContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: UIScreen.main.bounds.height * 0.6) bottomConstraint.isActive = true } private func setupInteractionGestures() { // 拖拽手势:允许向下拖拽关闭Sheet let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:))) contentContainer.addGestureRecognizer(panGesture) // 点击背景关闭Sheet let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissBottomSheet)) view.addGestureRecognizer(tapGesture) tapGesture.delegate = self } // 切换内容VC的方法,外部调用这个来替换显示的内容 func setContentViewController(_ vc: UIViewController) { // 移除旧的内容VC currentContentVC?.willMove(toParent: nil) currentContentVC?.view.removeFromSuperview() currentContentVC?.removeFromParent() // 添加新的内容VC addChild(vc) vc.view.translatesAutoresizingMaskIntoConstraints = false contentContainer.addSubview(vc.view) // 让内容VC完全填充容器 NSLayoutConstraint.activate([ vc.view.leadingAnchor.constraint(equalTo: contentContainer.leadingAnchor), vc.view.trailingAnchor.constraint(equalTo: contentContainer.trailingAnchor), vc.view.topAnchor.constraint(equalTo: contentContainer.topAnchor), vc.view.bottomAnchor.constraint(equalTo: contentContainer.bottomAnchor) ]) vc.didMove(toParent: self) currentContentVC = vc } // 显示Bottom Sheet的动画 func showSheet() { bottomConstraint.constant = 0 UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.5) { self.view.layoutIfNeeded() } } // 关闭Bottom Sheet的动画 @objc private func dismissBottomSheet() { bottomConstraint.constant = UIScreen.main.bounds.height * 0.6 UIView.animate(withDuration: 0.3) { self.view.layoutIfNeeded() } completion: { _ in self.dismiss(animated: false) // 重置容器引用,下次点击重新创建(可选,根据你的需求调整) if let rootVC = UIApplication.shared.keyWindow?.rootViewController as? RootViewController { rootVC.bottomSheetContainer = nil } } } // 处理拖拽手势逻辑 @objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) { let translation = gesture.translation(in: view) // 只允许向下拖拽 if translation.y > 0 { bottomConstraint.constant = translation.y } // 手势结束时判断是否关闭 if gesture.state == .ended { let velocity = gesture.velocity(in: view) // 拖拽速度快或者拖拽距离超过Sheet高度的一半,就关闭 if velocity.y > 300 || bottomConstraint.constant > UIScreen.main.bounds.height * 0.3 { dismissBottomSheet() } else { // 回弹到显示状态 bottomConstraint.constant = 0 UIView.animate(withDuration: 0.2) { self.view.layoutIfNeeded() } } } } } // 避免点击内容容器时触发背景的tap手势 extension BottomSheetContainerViewController: UIGestureRecognizerDelegate { func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { return touch.view == view } }
步骤2:在Root ViewController中使用
Root VC里有四个按钮,点击时切换对应的内容VC并展示Bottom Sheet:
import UIKit class RootViewController: UIViewController { // 保存Bottom Sheet容器的引用,实现复用 var bottomSheetContainer: BottomSheetContainerViewController? override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .white setupActionButtons() } private func setupActionButtons() { // 创建按钮栈视图 let buttonTitles = ["VC 1", "VC 2", "VC 3", "VC 4"] let stackView = UIStackView() stackView.axis = .vertical stackView.spacing = 16 stackView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(stackView) // 布局栈视图到屏幕中心 NSLayoutConstraint.activate([ stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor), stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor) ]) // 添加按钮 for title in buttonTitles { let button = UIButton(type: .system) button.setTitle(title, for: .normal) button.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .medium) button.addTarget(self, action: #selector(buttonTapped(_:)), for: .touchUpInside) stackView.addArrangedSubview(button) } } @objc private func buttonTapped(_ sender: UIButton) { // 根据按钮标题创建对应的内容VC(替换成你自己的VC类) let contentVC: UIViewController switch sender.title(for: .normal) { case "VC 1": contentVC = FirstContentViewController() case "VC 2": contentVC = SecondContentViewController() case "VC 3": contentVC = ThirdContentViewController() case "VC 4": contentVC = FourthContentViewController() default: contentVC = UIViewController() contentVC.view.backgroundColor = .lightGray } // 复用已有容器,或者创建新的 if let container = bottomSheetContainer { container.setContentViewController(contentVC) container.showSheet() } else { let container = BottomSheetContainerViewController() container.setContentViewController(contentVC) // 用overFullScreen模式,避免Root VC被隐藏 container.modalPresentationStyle = .overFullScreen present(container, animated: false) { container.showSheet() } bottomSheetContainer = container } } } // 示例内容VC,替换成你自己的VC即可 class FirstContentViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .cyan let label = UILabel() label.text = "第一个内容页面" label.textAlignment = .center label.font = UIFont.systemFont(ofSize: 20, weight: .bold) label.translatesAutoresizingMaskIntoConstraints = false view.addSubview(label) NSLayoutConstraint.activate([ label.centerXAnchor.constraint(equalTo: view.centerXAnchor), label.centerYAnchor.constraint(equalTo: view.centerYAnchor) ]) } } // 其他三个内容VC可以参考上面的写法,这里省略...
额外优化建议
- 添加拖拽指示器:在内容容器顶部加一个小横线(UIView),让用户更明确知道可以拖拽
- 自适应高度:根据内容VC的实际高度调整Sheet的高度,而不是固定60%
- 背景动画:显示时添加背景从透明到半透明的渐变动画,关闭时反向渐变
- 旋转适配:在
viewWillTransition(to:with:)方法中更新约束,避免旋转后布局错乱 - 支持多种高度:比如添加方法让外部指定Sheet的高度(比如全屏、半屏、自定义高度)
内容的提问来源于stack exchange,提问作者Ben Dev
相关产品推荐
相关产品推荐

