ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

iOS侧滑菜单栏开发实战:手势、动画与容器控制器全解析

iOS侧滑菜单栏开发实战:手势、动画与容器控制器全解析 简介一套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 / menuWidthprogress0 表示菜单完全关闭progress1 表示菜单完全展开。所有手势、动画、遮罩的透明度都可以映射到这一个 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 先 addSubviewcontent 后 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没有设置shadowPathCore 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转屏后菜单宽度不变内容页的宽度变成新的屏幕宽度打开状态 x280 保持不变这是合理的。如果你用比例宽度比如屏幕宽度的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 侧滑菜单栏做成真正跟手的产品级交互。本文还有配套的精品资源点击获取
返回列表