简介:一套iOS侧滑菜单栏工程源码,面向iOS开发初学者与初级工程师,演示了点击按钮后移动主视图以展示侧滑菜单的完整交互流程。资源共二十五个文件,压缩包约三十六KB,属于轻量级Objective-C示例工程。包内以MainViewController、LeftView、CenterView、CustomTabBar等类的头文件与实现文件为核心,同时包含工程配置文件、多语言字符串、图片资源目录和单元测试文件,源码结构清晰,可快速定位菜单栏、主视图、按钮事件与动画相关代码,并直接打开Xcode工程查看运行效果。目前已有199人学习下载,适合希望通过可运行Demo理解侧滑菜单实现思路的开发者。通过阅读代码,可掌握按钮事件触发视图位移、UIView动画过渡、主控制器与侧滑视图协作、自定义标签栏组织界面、Auto Layout适配不同屏幕,以及视图状态与背景交互管理等实用技巧。
1. ios 侧滑菜单栏:不是加个滑动手势那么简单
侧滑菜单栏在 iOS 上几乎是「我的」页面标配,但它远不是加个滑动手势那么简单。最常见的做法是左右两个 View 加一个 UIPanGestureRecognizer,拖动就改 frame,结果第二天就收到「列表滑不动」「返回手势失效」「低端机掉帧」的反馈。iOS 侧滑菜单栏的复杂度全在交互层:手势共存、动画中断、转屏布局、离屏渲染,每一项都能让 App 体验翻车。这篇文章以手写容器控制器为主线,从手势映射讲到产品级动画,把完整落地路径和踩坑记录都写清楚,适合不想被第三方库黑匣子拖着的 iOS 工程师。
2. 侧滑菜单栏的三种实现方案:手势容器、抽屉库与系统方案
2.1 侧滑菜单栏的交互模型:从「内容平移」到「遮罩开合」
先建立一个统一的交互模型:菜单页固定在屏幕左侧,内容页覆盖在上方并且不透明。当内容页向右平移 menuWidth 距离后,菜单页从左边缘露出来。这里有两个关键定义:progress = contentView.frame.origin.x / menuWidth,progress=0 表示菜单完全关闭,progress=1 表示菜单完全展开。所有手势、动画、遮罩的透明度都可以映射到这一个 progress 上。
理解这个模型很重要,因为网上很多侧滑菜单的教程会把菜单页的 frame 也一起移动,导致动画中途出现菜单和内容之间缝隙、或者阴影计算困难。我一般会坚持「内容动、菜单不动」的原则:菜单页始终放在 content 下层,不再给它加平移动画。延伸出来的变体是内容页缩放 + 菜单页缩放,类似 Path 那种 Z 轴效果,但那需要处理 anchorPoint 和 scale,成本高,对多数业务并不值得。
遮罩层也在这个模型里:在菜单页和内容页之间插一个半透明黑色 UIControl,它的 alpha 跟随 progress 从 0 到 0.35。这样菜单展开后,菜单区域有一个视觉压暗,点击遮罩可以直接关闭菜单。遮罩的 frame 始终等于容器 view.bounds,不随内容页移动,这样露出的左侧菜单区域才能点到它。
2.2 三种实现方案的横向对比与选型理由
项目里选择哪种方案,我把它当作一个「投入产出比」的问题,而不是「哪个技术更高级」的问题。常见的有三条路。第一条是调用成熟的第三方抽屉库,比如 RESideMenu、MMDrawerController 或者 Swift 时代的 SideMenu 这类库,它们封装好了手势、转屏和动画,集成很快。第二条是自己手写一个容器控制器,把手势、状态机、遮罩和动画全部握在自己手里。第三条是用 SwiftUI 的 ZStack + DragGesture 写一个,或者尝试用 UISplitViewController 做 iPad 主从布局。三者对比看这张表。
| 方案 | 代码量 | 手势可控性 | 转屏适配 | 排查难度 | 长期维护 |
|---|---|---|---|---|---|
| 第三方抽屉库 | 少 | 低,被封装成黑匣子 | 依赖库质量 | 难,冲突时只能读源码 | 依赖作者更新 |
| 手写 UIKit 容器 | 中等,约 300 行 | 高,每个参数可调 | 自己写 viewDidLayoutSubviews | 容易,逻辑是透明的 | 自己维护,稳 |
| SwiftUI + DragGesture | 中等 | 中等,受 SwiftUI 手势体系限制 | 中 | 中等 | 受最低系统版本限制 |
| UISplitViewController | 少 | 无,行为由系统定 | 好 | 无需排查 | 系统级,稳 |
第三方库的问题不是功能,而是当你遇到手势冲突时,你面对的是一个黑匣子。打个比方,滑动时感觉阻尼不对,你要去翻它的源码找到那个 0.6 的系数,然后理解它怎么嵌进你的页面。UISplitViewController 在 iPad 上是系统级主从,在 iPhone 上表现是 push 行为,做不了「侧滑菜单」这个形态,所以这条基本被排除。SwiftUI 方案我接触过一些,但一旦你的主结构还是 UIKit,比如集成高德地图、播放器、复杂列表,混编成本会把手势细节变成新的坑。综合下来,手写容器是长期最省心的方案,代码掌握了之后可以复用到多个项目,调整菜单宽度或者动画阻尼只是改两个常量的事。
2.3 我为什么最终选择自己写容器 VC
我在一个实际项目里踩过一次第三方库的坑:菜单打开的时候,左边边缘的滑动返回偶尔失效,App 在 iOS 15 和 iOS 16 上表现还不一致。去翻库的 issue 和源码,发现它的手势代理里同时处理了多个边缘手势,最后只能 patch。从那次之后,侧滑菜单这类「高频、交互重、性能敏感」的组件,我全部改成自己写。
手写容器 VC 还有一个隐性的好处:方便做菜单状态的持久化和业务解耦。你想让菜单项点击后切换内容页、想让菜单在冷启动时恢复上次展开状态、想在横屏时限制菜单宽度,这些需求第三方库往往要通过复杂代理或者 hack 来实现。自己写的话,这些就是普通方法调用。另一个不常被提到的点:容器 VC 作为子 VC 挂载,可以在菜单页和内容页分别做内存管理,内容页切换时可以用 beginAppearanceTransition 控制生命周期,体验更接近系统页面。
3. 用 UIPanGestureRecognizer 手写侧滑容器:核心代码与四个关键参数
3.1 容器 VC 骨架:挂载菜单页与内容页
先搭建容器的骨架。我选择用UIViewController容器的方式,而不是直接操作两个 UIView,因为这样菜单页和内容页可以各自维护自己的 ViewController 生命周期,转屏、内存警告都有系统兜底。
import UIKit final class SideMenuContainerViewController: UIViewController { enum MenuState { case closed, opened } private let menuWidth: CGFloat = 280 private let menuViewController: UIViewController private let contentViewController: UIViewController private var menuState: MenuState = .closed private var translationStartX: CGFloat = 0 private lazy var panGesture: UIPanGestureRecognizer = { let gesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:))) gesture.delegate = self return gesture }() init(menuViewController: UIViewController, contentViewController: UIViewController) { self.menuViewController = menuViewController self.contentViewController = contentViewController super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() setupChildren() contentViewController.view.addGestureRecognizer(panGesture) } private func setupChildren() { addChild(menuViewController) menuViewController.view.frame = CGRect(x: 0, y: 0, width: menuWidth, height: view.bounds.height) view.addSubview(menuViewController.view) menuViewController.didMove(toParent: self) addChild(contentViewController) contentViewController.view.frame = view.bounds view.addSubview(contentViewController.view) contentViewController.didMove(toParent: self) } @objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) { } }关键点是 menu 和 content 的添加顺序。menu 先 addSubview,content 后 addSubview,所以 content 覆盖在 menu 上层。content 的 view 默认背景不为透明,初始状态就完全遮住菜单。手势加在 content 的 view 上,而不是容器 view 上,这样手指必须落在内容区域才会触发菜单滑动,避免菜单页上的按钮被手势干扰。菜单宽度的两个常见取值是固定 280pt 或者屏幕宽度的 0.8,我习惯固定 280,因为 iPhone 宽度范围有限,280 在单手操作时右侧还能露出大约 100pt 的内容提示用户菜单存在。
3.2 手势位移映射:从手指移动距离到 content.frame 偏移
接下来是核心的手势处理。这一步最容易犯的错是直接用gesture.translation(in: view).x作为 content 的 x 坐标,那样会导致手指从哪里开始拖动,content 就瞬间跳到哪里。正确做法是记下手势开始时的 content 位置,再累加位移。
@objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) { let translation = gesture.translation(in: view) let velocity = gesture.velocity(in: view) switch gesture.state { case .began: let presentationX = contentViewController.view.layer.presentation()?.frame.minX ?? contentViewController.view.frame.minX translationStartX = presentationX contentViewController.view.layer.removeAllAnimations() contentViewController.view.frame.origin.x = presentationX case .changed: var targetX = translationStartX + translation.x targetX = min(max(targetX, 0), menuWidth) contentViewController.view.frame.origin.x = targetX updateMenuAppearance(progress: targetX / menuWidth) case .ended, .cancelled, .failed: let currentX = contentViewController.view.frame.minX let shouldOpen = currentX > menuWidth * 0.5 || velocity.x > 600 if shouldOpen { animateToOpen() } else { animateToClose() } default: break } }.began里读取presentationLayer?.frame.minX是为了处理「动画没结束,用户又把手放上去」的情况。如果直接读.frame,拿到的可能是动画目标值,比如菜单已经在动画向 280 移动,但画面上还在 100,你一读就拿到 280,手势起点瞬间跳出去。removeAllAnimations()之后要把 presentation 的值写回模型层,否则 view.frame 还停在动画目标。translation(in: view)是手势从 began 到当前的总位移,所以必须和translationStartX相加,不能直接赋值。targetX被 clamp 到 0 到 menuWidth 之间,防止拖过头或拖成负数。
3.3 回弹判定:位置阈值、速度阈值与动画阻尼
手势结束时,要不要展开菜单,我同时看两个条件:位置过半,或者速度足够快且方向向右。位置阈值取 0.5 比较自然,但速度阈值需要根据菜单宽度调整,600pt/s 是一个在 280pt 宽度下比较合适的初值。如果菜单宽度改到 320,速度阈值建议同步放大到 700,否则会出现用户轻轻一滑菜单就飞出去的情况。
private func animateToOpen() { menuState = .opened UIView.animate( withDuration: 0.32, delay: 0, usingSpringWithDamping: 0.85, initialSpringVelocity: 0.6, options: [.curveEaseOut, .beginFromCurrentState] ) { [weak self] in guard let self = self else { return } self.contentViewController.view.frame.origin.x = self.menuWidth self.updateMenuAppearance(progress: 1.0) } } private func animateToClose() { menuState = .closed UIView.animate( withDuration: 0.28, delay: 0, usingSpringWithDamping: 0.9, initialSpringVelocity: 0.2, options: [.curveEaseOut, .beginFromCurrentState] ) { [weak self] in guard let self = self else { return } self.contentViewController.view.frame.origin.x = 0 self.updateMenuAppearance(progress: 0.0) } }usingSpringWithDamping的取值值得多说一句。系统导航栏的 pop 手势大约在 0.85 左右,侧滑菜单如果太弹会显得轻浮,太硬又显得死板。0.85 到 0.9 之间是大多数 App 的甜点区间。initialSpringVelocity这里先写固定值,更精细的做法是把手势结束时的 velocity.x 除以 menuWidth 归一化后传进来,这一点在第 5 章展开。beginFromCurrentState这个选项很重要,它让动画从 presentation layer 当前的位置开始,而不是先跳回模型层位置,能消除闪变。
3.4 遮罩与菜单开关的对外接口
一个完整的侧滑菜单不能少了遮罩。遮罩既是视觉层,也是交互层。我一般在 viewDidLoad 里把遮罩插入到 menu 和 content 之间,并给一个 tap 事件用来关闭菜单。
private lazy var overlayView: UIControl = { let view = UIControl(frame: .zero) view.backgroundColor = UIColor.black.withAlphaComponent(0.3) view.alpha = 0 view.addTarget(self, action: #selector(closeByTappingOverlay), for: .touchUpInside) return view }() // 在 viewDidLoad 的 setupChildren 中调用 private func setupOverlay() { overlayView.frame = view.bounds view.insertSubview(overlayView, aboveSubview: menuViewController.view) } private func updateMenuAppearance(progress: CGFloat) { overlayView.alpha = progress * 0.35 } @objc private func closeByTappingOverlay() { animateToClose() }遮罩插在 menu 之上、content 之下,所以 content 平移之后,左侧露出的部分就是带遮罩的菜单区域。点击遮罩关闭菜单,这是iOS 侧滑菜单的标准手势之一。对外接口除了 openMenu 和 closeMenu,我还会暴露一个 toggleMenu 方法,方便导航栏汉堡按钮直接调用:
func openMenu(animated: Bool = true) { guard menuState == .closed else { return } if animated { animateToOpen() } else { contentViewController.view.frame.origin.x = menuWidth updateMenuAppearance(progress: 1) menuState = .opened } } func closeMenu(animated: Bool = true) { guard menuState == .opened else { return } if animated { animateToClose() } else { contentViewController.view.frame.origin.x = 0 updateMenuAppearance(progress: 0) menuState = .closed } }非动画版本用在冷启动恢复状态或单元测试里,它直接摆到目标位置,不经过动画,避免和启动页过渡动画叠加产生怪异的视觉效果。
4. 侧滑菜单栏避坑指南:5 个最常见问题与排查方法
4.1 手势被 UITableView 拦截:菜单拉不出来
现象:主内容页是列表时,在 cell 上向右滑,菜单几乎没有反应;上下滚动列表时,菜单又偶尔跟着手指往外带。
原因:UITableView 内部有一个 UIPanGestureRecognizer,它和我们的菜单 pan 手势形成了竞争。系统在 UIGestureRecognizer 的默认判定里,通常会让 scroll view 的 pan 手势胜出,因为滚动是高频操作,水平拖拽只有在明确方向时才会被承认。
解决:让菜单手势的代理在「菜单关闭」状态下,只识别「横向位移明显大于纵向位移」且「速度方向向右」的手势。纵向滚动交给 table view,横向右滑才交给菜单。具体在 delegate 里这样写:
extension SideMenuContainerViewController: UIGestureRecognizerDelegate { func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true } if menuState == .opened { return true } let velocity = pan.velocity(in: view) let isHorizontal = abs(velocity.x) > abs(velocity.y) let isRight = velocity.x > 0 return isHorizontal && isRight } func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer ) -> Bool { guard let scrollView = otherGestureRecognizer.view as? UIScrollView else { return false } return scrollView.contentOffset.x <= 0 } }shouldRecognizeSimultaneouslyWith这里返回 true 的情况是:scroll view 还在最左侧(contentOffset.x <= 0),这时菜单手势和滚动可以同时识别;一旦列表已经往左偏移,菜单手势就不应该再插手,否则容易在横向滑动的轮播图里误触。还要注意,菜单关闭时打开手势需要判断 velocity,但菜单打开时不再限制,因为此时用户可能从任意角度向左滑关闭菜单。
4.2 content 阴影不跟随:滑动时掉帧
现象:给 content view 加了灰色的阴影,拖动时阴影要么不动,要么整个动画一卡一卡,在 iOS 15 的旧设备上特别明显。
原因:阴影的默认计算是走离屏渲染的。如果只设置shadowColor、shadowOpacity、shadowRadius,没有设置shadowPath,Core Animation 就要在每一帧动态计算阴影形状。而在拖帧过程中,content 的 frame 每一帧都在变,叠加圆角或者 maskToBounds 时,离屏渲染的开销会放大到肉眼可感知的掉帧。
解决:给 content 的 layer 设置一个明确的矩形 shadowPath,并且通常只让阴影出现在 content 的右侧和下方。这个 path 不随 frame 的 origin 变化而变化,所以拖动过程零重算。
private func setupContentShadow() { let contentView = contentViewController.view contentView.layer.shadowColor = UIColor.black.cgColor contentView.layer.shadowOpacity = 0.25 contentView.layer.shadowRadius = 8 contentView.layer.shadowOffset = .zero contentView.layer.shadowPath = UIBezierPath(rect: contentView.bounds).cgPath contentView.layer.masksToBounds = false }每次 menu 或 content 的 frame 修改后,只要 contentView.bounds 没变,shadowPath 就不需要更新。如果你发现阴影本身有锯齿,往往是因为 shadowPath 和实际视图形状不一致,而不是性能问题。菜单页如果要切左上角和左下角的圆角,我建议用CAShapeLayer画路径,不要直接给 layer 开cornerRadius又开masksToBounds,那又是一次离屏渲染。
4.3 转屏后菜单宽度错乱
现象:iPhone 横屏后再竖回来,菜单条高度撑不满,或者内容页停在一个很奇怪的位置,没有贴到菜单宽度边缘。
原因:viewDidLoad 只执行一次,里面的 frame 设置是针对初始屏幕方向的。转屏后容器 view.bounds 变了,如果我们不在 layout 阶段更新子 view 的 frame,内容页还保留旧宽度。
解决:把 frame 的调整集中在viewDidLayoutSubviews,并且用 menuState 来决定 content 的 x。不要直接在 viewDidLayoutSubviews 里写contentViewController.view.frame = view.bounds,那会把打开的菜单强制拽回去。
override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() menuViewController.view.frame = CGRect( x: 0, y: 0, width: menuWidth, height: view.bounds.height ) overlayView.frame = view.bounds contentViewController.view.frame = CGRect( x: menuState == .opened ? menuWidth : 0, y: 0, width: view.bounds.width, height: view.bounds.height ) }如果你用了固定 menuWidth,转屏后菜单宽度不变,内容页的宽度变成新的屏幕宽度,打开状态 x=280 保持不变,这是合理的。如果你用比例宽度(比如屏幕宽度的0.8),转屏后需要重算 menuWidth,并且把 content 的 x 也按比例缩放,否则会出现菜单露出宽度不对的情况。方案本身没有优劣,但不要混用:固定宽度就用固定值,比例宽度就全按比例。
4.4 和导航控制器侧滑返回手势打架
现象:内容页是 navigationController push 进入的,从屏幕左边缘向右滑,本该触发上一页的 pop 返回,结果菜单先弹出来了,或者反过来,菜单没出来但页面退回去了。
原因:UINavigationController 自带interactivePopGestureRecognizer,它也是从屏幕左边缘水平右滑。两个手势都命中同一个起始区域,系统默认判定很可能让先注册的手势先赢,而这个顺序在不同 iOS 版本上有过变化。
解决:一个常见做法是让 pop 手势「等待」菜单手势失败后再识别,也就是require(toFail:),这样左边缘滑动会优先被菜单吃掉。但这样做会让正常导航返回失效,所以必须配合菜单状态判断。
// 在 viewDidLoad 或 viewWillAppear 中调用 if let popGesture = navigationController?.interactivePopGestureRecognizer { popGesture.require(toFail: panGesture) }这里require(toFail:)的含义是:pop 手势要等 pan 失败后才识别。于是菜单手势一旦开始识别,pop 就让位。反过来,如果用户想 pop,而菜单手势因为速度方向判断没有识别,pan 失败,pop 正常执行。关键在于 4.1 的gestureRecognizerShouldBegin里菜单关闭时的方向判断,如果菜单手势在任何右滑时都一定要识别,pop 就会被彻底堵死。所以这两条是配套的,不要只配一条。
4.5 全屏手势误触:加起始区域判断
现象:有些产品要求全屏右滑打开菜单,但主内容页上有横向滚动的轮播图、图片浏览器或者一个可横向拖动的进度条,用户在这些区域右滑时菜单误触频繁。
原因:全屏手势只判方向,不判起始区域,导致任何横向右滑都被当成菜单手势。
解决:给gestureRecognizerShouldBegin增加起始区域判断,只让屏幕左边缘 30pt 内开始的手势打开菜单。这个 30pt 是从 iOS 系统侧滑返回手势的习惯沿袭下来的,对拇指操作来说也足够命中。如果想保留全屏手势,那就不要加边缘判断,但要接受和横向滚动控件的冲突,只能靠顶层的 scrollView 代理去兜底。
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true } if menuState == .opened { return true } let locationX = pan.location(in: view).x let isEdgeSwipe = locationX < 30 let velocity = pan.velocity(in: view) let isRightDrag = velocity.x > 200 return isEdgeSwipe && isRightDrag }这一条和 4.1 的区别在于,4.1 处理的是和 scroll view 的竞争,这一条处理的是用户误触。实际项目里两者都要写,4.1 的方向判断和同时识别判断解决列表滚动,本条解决轮播图、边缘误触。如果菜单已经打开,从任何位置向左滑动都应该是关闭手势,所以先判断 menuState == .opened。
5. 把侧滑菜单栏提升到产品级:可中断动画、状态持久化与协议解耦
5.1 用 UIViewPropertyAnimator 做可中断动画
第 3 章用的是UIView.animate,应付简单场景足够,但它有一个硬伤:用户松手后动画进行中,再次触摸屏幕想改变方向,系统还沿旧动画走,重新开始手势时会先跳一下。产品级方案是用UIViewPropertyAnimator,它把动画变成一个可调整 progress 的对象,手势过程中可以随时暂停、改变进度、反向继续。
private var animator: UIViewPropertyAnimator? private func createAnimator(toOpen: Bool) -> UIViewPropertyAnimator { let targetX: CGFloat = toOpen ? menuWidth : 0 let animator = UIViewPropertyAnimator(duration: 0.32, curve: .easeOut) { [weak self] in guard let self = self else { return } self.contentViewController.view.frame.origin.x = targetX self.updateMenuAppearance(progress: toOpen ? 1 : 0) } animator.addCompletion { [weak self] position in guard let self = self else { return } self.menuState = position == .end ? (toOpen ? .opened : .closed) : self.menuState self.animator = nil } return animator } func updateProgressWithGesture(_ progress: CGFloat) { if animator == nil { // 手势开始但还没有创建动画,先基于当前 progress 创建 animator = createAnimator(toOpen: progress > 0.5) } animator?.fractionComplete = progress }在手势.changed里把targetX / menuWidth传给updateProgressWithGesture,手势结束时调用continueAnimation或者reverse。这套机制的好处是,动画始终从当前 presentation layer 位置继续,不会跳变。注意不要在 completion 里过度依赖 position,如果动画被 reverse 回起点,position 是.start,此时 menuState 应该维持原值而不是翻转。
5.2 动画曲线与弹簧参数:UIViewPropertyAnimator 的调参
很多人把阻尼系数当成玄学,其实有规律。我在第 3 章的UIView.animate里用的是固定initialSpringVelocity,在UIViewPropertyAnimator里可以更精细:把手势结束时的瞬时速度归一化后作为 spring 初速度。归一化方法是用 velocity.x 除以 menuWidth,得到的值就是动画需要覆盖的目标距离和当前速度的比值。
let normalizedVelocity = abs(velocity.x) / menuWidth animator?.continueAnimation( withTimingParameters: UISpringTimingParameters( dampingRatio: 0.85, initialVelocity: CGVector(dx: normalizedVelocity, dy: 0) ), durationFactor: 1.0 )dampingRatio0.85 是带一点回弹但不过分的值。如果想让菜单有轻微「过头再回来」的手感,调到 0.7;如果是商务办公类 App,想显得克制,调到 0.95。durationFactor保持 1.0,代表让系统根据 timingParameters 自动决定实际时长,不要自己写死。注意initialVelocity的 dx 单位不是 pt/s,而是「相对总距离的比例」,所以必须先归一化。很多第三方库手感奇怪就是因为少了这一步,直接把 velocity 塞进去。
5.3 菜单状态恢复:启动时是否回到上次的位置
冷启动要不要恢复菜单打开状态,这是产品决策,不是技术决策。金融类和效率类 App 倾向于不恢复,因为用户希望一进来就在主页面;但阅读类 App 可能需要恢复上次阅读的位置,菜单状态也可以一起恢复。如果要做,建议用 UserDefaults 存一个 Bool,并且在 read 的时候不要播放动画,直接摆好位置,避免启动时一个菜单滑出来的动画和首页 push 动画叠在一起。
private enum SideMenuPrefs { static let lastStateKey = "sideMenu.lastState" } func saveCurrentState() { UserDefaults.standard.set(menuState == .opened, forKey: SideMenuPrefs.lastStateKey) } func restoreStateIfNeeded() { let wasOpened = UserDefaults.standard.bool(forKey: SideMenuPrefs.lastStateKey) guard wasOpened else { return } contentViewController.view.frame.origin.x = menuWidth updateMenuAppearance(progress: 1) menuState = .opened }在 App 进入后台或者容器 deinit 时调用saveCurrentState()。注意要在applicationDidEnterBackground里调,不要等到applicationWillTerminate,因为那个方法在 iOS 上不保证触发。恢复时直接改 frame 和 alpha,不经过动画,这个「没那么自然」的设计其实是有意的:启动瞬间用户注意力还在启动屏,不该被一个非必须的动画分散。
5.4 菜单项跳转解耦:一个协议搞定容器与业务页面
侧滑菜单大多数不是静态展示,菜单项点击后要切换内容页。最忌讳的是菜单页直接持有容器强引用,然后让容器去替换子 VC,那样两层耦合会越来越紧。我一般会给菜单页定义一个代理协议,容器实现它。
protocol SideMenuActionHandling: AnyObject { func sideMenuDidSelect(item: SideMenuItem) } struct SideMenuItem { let title: String let iconName: String } final class SideMenuViewController: UIViewController { weak var actionHandler: SideMenuActionHandling? // 菜单项点击后: // actionHandler?.sideMenuDidSelect(item: item) }容器在创建菜单页时把自己赋值给actionHandler,并在协议方法里拿到 item 后判断应该切换哪个内容页。协议本身不依赖 UIKit 以外的类型,菜单页可以脱离容器单独在 Xcode 预览里调试,方便很多。这里有个细节:actionHandler要用 weak,不然容器持有菜单页、菜单页又持有容器,循环引用让 deinit 永远不执行。我自己踩过这个坑,后来在 deinit 里打 log 发现整个容器一直不被释放,翻了好几个小时。
6. 侧滑菜单栏的调试技巧:给手势区域和 progress 加可视化画板
最后一章分享一个实际调试技巧。侧滑菜单的很多问题不是逻辑错了,而是参数边界不对,比如 edge 30pt 到底够不够、progress 在哪个区间应该开、动画有没有跳变。这些很难靠肉眼从模拟器里判断,我更习惯在容器里加一个调试画板:一个半透明的 DebugView,把手势起始区域和当前 progress 实时渲染出来。
做法不复杂。在手势handlePanGesture里用一个全局变量记录状态,再把这个状态同步到一个独立的悬浮 layer 上。我用一个 CALayer 画在view.window上,它只参与 Debug 环境,Release 里通过#if DEBUG隔绝。
#if DEBUG final class SideMenuDebugOverlay: UIView { var progress: CGFloat = 0 { didSet { setNeedsDisplay() } } var isEdgeArea: Bool = false { didSet { setNeedsDisplay() } } override func draw(_ rect: CGRect) { guard let context = UIGraphicsGetCurrentContext() else { return } // 画起始区域 context.setFillColor(UIColor.systemBlue.withAlphaComponent(isEdgeArea ? 0.6 : 0.2).cgColor) context.fill(CGRect(x: 0, y: 0, width: 30, height: bounds.height)) // 画 progress 标尺 let barY: CGFloat = 100 context.setStrokeColor(UIColor.black.cgColor) context.stroke(CGRect(x: 10, y: barY, width: 260, height: 4)) context.setFillColor(UIColor.red.cgColor) context.fill(CGRect(x: 10, y: barY, width: progress * 250, height: 4)) } } #endif在手势.changed里把targetX / menuWidth赋给 overlay 的 progress,把pan.location(in: view).x < 30赋给 isEdgeArea。这样你手指滑的时候,屏幕上会同时看到蓝色起始区域和红色进度条,参数调没调对一目了然。我曾经把一个项目的速度阈值从 600 调高到 800,从数据上看不直观,但配着进度条和区域色块,能明显感觉到用户快速滑动时红色进度条是否在一开始就超过了半程。这个调试方式在真机上同样可用,建议连接 xcode 的 Debug View Hierarchy 一起看。侧滑菜单栏的坑,绝大多数集中在手势边界和动画状态这两块,可视化画板能让这些黑匣子变透明。希望这一套方法论能帮你少踩几次坑,把 ios 侧滑菜单栏做成真正跟手的产品级交互。
本文还有配套的精品资源,点击获取