尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
iOS侧滑菜单实战:手势让路与状态机设计全解析
简介面向iOS应用开发者的侧滑菜单栏实现示例对应通过点击按钮移动当前视图以展示侧滑面板的常见交互需求。资源包内含完整Xcode工程源码共25个文件以Objective-C的.m与.h源文件为主另有plist配置、strings本地化、json及工程配置文件等压缩包仅36KB结构精简便于快速定位核心逻辑。代码中演示了创建侧滑视图、添加UIButton触发事件、修改frame或transform完成位移动画并结合Auto Layout适配不同屏幕同时涵盖状态标记与交互优先级处理帮助理解菜单栏显示/隐藏的完整流程。工程包含主视图控制器、侧滑视图、自定义标签栏等模块可清晰看到各组件如何协作通过阅读源码能掌握最小可用工程的组织方式并了解自定义视图与控制器之间的数据传递适合刚接触iOS界面交互的开发者对照练习也可作为扩展手势识别的起点。目前已有200人学习下载。1. 一个反直觉的结论侧滑菜单的难度全在“手势让路”侧滑菜单在 iOS 里是个老得不能再老的需求。网上随便一搜就能翻出几十个 Demo但真正把“侧滑菜单”接到实际工程里顺利跑过真机、iPad、横竖屏和键盘弹起的场景靠的已经不是动画代码了——我手头这套组件从手势接进来第一行代码到最后解决菜单打开时遮罩层拦截点击、导航栏边缘返回手势失效的问题前后折腾了差不多三周。这个项目说白了就是一套完整的 iOS 侧滑菜单实现主界面是 UITableView左侧滑出菜单右侧留白难点不在“把菜单滑出来”的动画而在手势识别器怎么在 UITableView 滚动、系统导航返回手势、键盘弹起之间互相让路。资源里包含完整的手势驱动模块、菜单容器控制器、遮罩交互和动画参数配置适合想把侧滑菜单快速接进现有工程、又不愿引入一整个重型框架的开发者。2. 架构设计把菜单和主界面拆成两个互不干扰的状态机2.1 模块划分与职责边界我接这个需求时第一件事不是写代码是画边界。侧滑菜单这个功能看起来简单实际上由四件事组成手势要不要响应、菜单内容展示在哪儿、菜单打开后点击怎么处理、主界面怎么恢复原状。常见的翻车写法是把四件事全揉进主控制器里结果一个文件里出现七八个手势 delegate 方法改一处崩三处。我在这里坚持用两个类把职责分开XXSideMenuController只负责容器状态XXSideMenuPanGesture只负责手势判定。前者不知道后者怎么识别手势后者也不知道菜单里的 cell 点击之后该跳哪个页面。这样后续扩展左右双侧菜单、嵌套菜单都只用改一个类不用动另一个。挂载到现有工程的步骤很简单把根控制器替换成容器控制器就行// AppDelegate.m 里的示意 - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { self.window [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; UIViewController *mainVC [[UINavigationController alloc] initWithRootViewController:[[HomeViewController alloc] init]]; UIViewController *menuVC [[MenuViewController alloc] init]; XXSideMenuController *sideMenu [[XXSideMenuController alloc] initWithContentController:mainVC menuController:menuVC]; self.window.rootViewController sideMenu; [self.window makeKeyAndVisible]; return YES; }这里把contentController和menuController作为两个独立的子控制器传给容器业务方原来的UINavigationController可以原封不动塞进去菜单控制器内部用 UITableView 还是 UIStackView 都随你。这种写法的好处是业务代码零入侵——主界面不需要知道菜单的存在菜单也不需要反向依赖主界面的任何内部状态。2.2 核心类设计与交互链路整个交互链路是这样的用户手指在屏幕横向滑动XXSideMenuPanGesture的 targetAction 里拿到UIPanGestureRecognizer的 translation把位移换算成 content 视图的 x 偏移量同时把偏移量按比例映射到遮罩层的 alpha。手指抬起后根据最终偏移量和速度判断是打开、关闭还是回弹。这个链路里有一个经常被忽略的细节手势响应期间不能让 UITableView 自己响应滚动也不能让UIScreenEdgePanGestureRecognizer响应导航返回。我在代码里没有用“禁用手势”的粗暴办法而是给两个手势各自设置状态让它们通过 delegate 方法互相让路。关键类的关系用文字描述是XXSideMenuController持有 contentController 和 menuController负责子控制器的生命周期和状态迁移。XXSideMenuPanGestureUIPanGestureRecognizer的轻量子类保留最近一次滑动的 velocity供动画回弹时计算初速度。XXMenuMaskView遮罩视图挂在 contentController.view 之上alpha 随位移变化点击时通知容器关闭菜单。类之间没有循环引用菜单内容的点击回调通过 block 向外抛容器控制器一概不管菜单里面是什么业务。2.3 状态机设计三态迁移代替直接改视图我习惯把菜单状态抽象成closed、opened、dragging三态。closed和opened是稳态dragging是过渡态。所有动画回调、手势回调里只处理状态迁移不直接改视图 frame。这样做的直接收益是万一手指在拖拽过程中 App 进入后台、屏幕旋转或者接了个电话状态机能把界面强制拉回某个稳态不会出现半截菜单卡在屏幕中间的尴尬画面。三个状态的迁移规则closed到dragging手势 begancontent 视图开始跟随手指遮罩层出现。dragging到opened手势 ended位移超过阈值且速度向右进入打开动画。dragging到closed位移不足或速度不足回弹到初始位置。状态迁移代码里有一个点很关键所有动画都基于transform而不是 frame。用 frame 做动画在 iPhone 上看着还行一到 iPad 分屏或者横竖屏切换frame 缓存失效后布局全乱transform 不参与 autolayout 计算源视图的约束不需要动。3. 核心代码逐段拆解从手势识别到动画关闭的完整链路3.1 手势识别器的设置与 delegate 回调手势识别器在容器控制器的 viewDidLoad 里统一挂载遮罩手势也一并配置// XXSideMenuController.m - (void)viewDidLoad { [super viewDidLoad]; [self addChildViewController:self.contentController]; [self.view addSubview:self.contentController.view]; [self.contentController didMoveToParentViewController:self]; [self addChildViewController:self.menuController]; [self.view insertSubview:self.menuController.view belowSubview:self.contentController.view]; [self.menuController didMoveToParentViewController:self]; self.menuWidth 280.0f; self.menuController.view.frame CGRectMake(0, 0, self.menuWidth, self.view.bounds.size.height); self.menuController.view.transform CGAffineTransformMakeTranslation(-self.menuWidth, 0); UIPanGestureRecognizer *pan [[UIPanGestureRecognizer alloc] initWithTarget:self action:selector(handlePan:)]; pan.delegate self; [self.view addGestureRecognizer:pan]; UITapGestureRecognizer *tap [[UITapGestureRecognizer alloc] initWithTarget:self action:selector(handleTap:)]; [self.maskView addGestureRecognizer:tap]; }这段代码里有三个细节值得拎出来说。第一menuController.view初始状态用transform向左移出屏幕而不是把 frame 设为负数——这样在做回弹动画时可以直接把 transform 归零不需要记两套 frame。第二遮罩层的 tap 手势没有加在 self.view 上而是加在 maskView 上maskView 初始hidden为 YES避免手势挂在根视图上影响主界面正常点击。第三容器控制器正确地调用了addChildViewController和didMoveToParentViewController子控制器能接收到旋转、内存警告等系统回调。接下来是手势 delegate 的关键部分。这里决定的是“什么时候这个手势可以让路”- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer { if (gestureRecognizer self.panGesture) { CGPoint velocity [self.panGesture velocityInView:self.view]; // 纵轴速度大于横轴速度时判定为列表滚动而不是菜单滑动 if (fabs(velocity.y) fabs(velocity.x)) { return NO; } return YES; } return YES; } - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer { // 与系统边缘返回手势同时触发时让系统手势先失败 if ([otherGestureRecognizer isKindOfClass:[UIScreenEdgePanGestureRecognizer class]]) { return NO; } return NO; }gestureRecognizerShouldBegin通过比较 velocity 的横纵轴绝对值把“上下滑列表”和“左右滑菜单”区分开。注意这不是判断手指移动方向而是判断滑动初速度方向。手指先横后竖的斜线滑动velocity 里横轴分量更大走菜单逻辑反之走列表滚动。shouldRecognizeSimultaneously统一返回 NO意味着这组手势之间是互斥的要么菜单响应要么系统和列表响应不会出现两个手势同时工作的乱局。3.2 拖拽过程中的位移计算与遮罩联动手势真正开始后核心是位移计算。translationInView拿到的坐标是累计位移每次手势周期从 began 到 ended它不会自动清零所以位移增量要在 began 阶段手动归零一次。- (void)handlePan:(UIPanGestureRecognizer *)pan { CGPoint translation [pan translationInView:self.view]; CGPoint velocity [pan velocityInView:self.view]; switch (pan.state) { case UIGestureRecognizerStateBegan: { self.state XXSideMenuStateDragging; break; } case UIGestureRecognizerStateChanged: { // 左侧菜单右滑为正目标位移限定在 0menuWidth 之间 CGFloat targetX MIN(self.menuWidth, MAX(0, translation.x)); self.contentController.view.transform CGAffineTransformMakeTranslation(targetX, 0); // 遮罩透明度跟随位移线性变化最大 0.5 self.maskView.alpha 0.5 * targetX / self.menuWidth; break; } case UIGestureRecognizerStateEnded: { // 速度大于 500 或位移超过宽度 40% 时打开否则回弹 if (velocity.x 500 || translation.x self.menuWidth * 0.4) { [self openMenuWithVelocity:velocity]; } else { [self closeMenuWithVelocity:velocity]; } break; } default: break; } }MIN和MAX的夹逼把 targetX 限制在 0 到 menuWidth 之间避免菜单被拖出屏幕右侧。遮罩层的 alpha 我设成最大 0.5如果你觉得太黑或者太浅直接改这里的系数不用去动画回调里另写一套。这个线性映射我一般不用UIPanGestureRecognizer的velocityInView做实时速度因为动画结束时的 velocity 才真正决定弹簧动画的初速度实时速度反而会引起视觉抖动。3.3 打开与关闭动画的实现细节动画部分我用UIViewPropertyAnimator思路的简化版即UIView animateWithDuration加弹簧参数。弹簧动画的阻尼系数和初速度是控制手感的关键阻尼设太大会感觉像被刹车拽住设太小又会在终点附近来回弹好几下。- (void)openMenuWithVelocity:(CGPoint)velocity { self.state XXSideMenuStateOpened; [UIView animateWithDuration:0.28 delay:0 usingSpringWithDamping:0.9 initialSpringVelocity:fabs(velocity.x) / 1000 options:UIViewAnimationOptionCurveEaseOut animations:^{ self.contentController.view.transform CGAffineTransformMakeTranslation(self.menuWidth, 0); self.maskView.alpha 0.5; } completion:^(BOOL finished) { if (finished) { self.maskView.userInteractionEnabled YES; } }]; } - (void)closeMenuWithVelocity:(CGPoint)velocity { self.state XXSideMenuStateClosed; [UIView animateWithDuration:0.2 delay:0 usingSpringWithDamping:1.0 initialSpringVelocity:fabs(velocity.x) / 1000 options:UIViewAnimationOptionCurveEaseIn animations:^{ self.contentController.view.transform CGAffineTransformIdentity; self.maskView.alpha 0.0; } completion:^(BOOL finished) { if (finished) { self.maskView.userInteractionEnabled NO; self.maskView.hidden YES; } }]; }注意两个动画的时长不同打开比关闭慢因为打开时用户预期是“展开”带一点阻尼感关闭时用户预期是“立刻回到主界面”所以时长更短、阻尼为 1.0。userInteractionEnabled的状态切换放在 completion 里这是为了确保动画没结束时遮罩不可点击动画一停才恢复交互。这个顺序如果写反了会出现非常诡异的“菜单还在飞点击已经穿透到主界面”的问题。3.4 遮罩点击与键盘处理菜单打开后点击遮罩区域要关闭菜单。遮罩的 tap 手势已经在 viewDidLoad 里挂上响应方法里做一次状态判断即可- (void)handleTap:(UITapGestureRecognizer *)tap { if (self.state XXSideMenuStateOpened) { [self closeMenuWithVelocity:CGPointZero]; } }这里判断state而不是判断maskView.hidden原因在于动画执行期间状态已经切到 opened但 maskView 的 hidden 要等动画 completion 才改手指在动画中点击遮罩也应该关闭菜单用 hidden 判断会漏掉这种情况。键盘弹起是另一个容易忽略的点。如果主界面有输入框滑出菜单时键盘还挂在屏幕上键盘所在的 window 和主 window 是两块独立层级transform 位移只动了主 window 里的视图键盘不会跟着走。我一般在菜单打开前统一收键盘- (void)prepareMenuInteractive { [self.view endEditing:YES]; [[NSNotificationCenter defaultCenter] removeObserver:self name:UIKeyboardWillShowNotification object:nil]; }用endEditing:YES强制第一响应者 resign键盘收起后菜单再滑出视觉上不会出现键盘和菜单重叠的错乱。不要把键盘监听和菜单动画扯在一起那样会在动画回调里多出一堆和布局无关的坐标计算。4. 跑起来调试参数配置、真机联调与状态验证4.1 目录结构与关键文件工程目录沿用了常见的 MVC 划分菜单相关内容集中在SideMenu目录下SideMenu/ ├── XXSideMenuController.h ├── XXSideMenuController.m ├── XXSideMenuPanGesture.h ├── XXSideMenuPanGesture.m ├── XXMenuMaskView.h ├── XXMenuMaskView.m └── MenuViewController/ ├── MenuViewController.h └── MenuViewController.mXXSideMenuController是组件核心负责容器与状态迁移XXSideMenuPanGesture是手势子类MenuViewController是业务方的菜单页里面的 cell 点击通过 block 回调到容器外部。这种拆分方式在源码阅读和二次开发时都很好定位问题。4.2 参数调节与调试验证组件开放了一组可调参数我整理了一张配置表参数默认值作用调整建议menuWidth280菜单展开宽度按屏幕宽度比例调整iPad 建议 320openDuration0.28打开动画时长想干脆利落就调到 0.2closeDuration0.2关闭动画时长一般保持小于打开时长velocityThreshold500快速滑动判定阈值想更灵敏地触发打开就调小到 300distanceRatio0.4位移比例阈值拖过 40% 宽度即打开maskAlpha0.5遮罩最大透明度暗色主题可调到 0.6所有参数集中在控制器顶部宏定义区改完之后直接 Run不需要动手势代码。验证手势互斥是否生效建议在真机上测两个场景菜单关闭时快速上下滚动主界面列表列表应该顺滑滚动、菜单不能露出边菜单打开后再横向滑动遮罩响应点击关闭主界面不能跟着位移。模拟器上由于鼠标手势没有真实的初速度velocityThreshold 的判断偶尔会显得迟滞这是鼠标事件和触摸事件的差异不是代码问题。4.3 通过日志验证状态迁移调试时我喜欢在三个状态迁移点各打一行日志- (void)setState:(XXSideMenuState)state { _state state; NSLog([SideMenu] state - %, [closed, dragging, opened][state]); }状态日志能快速暴露“状态没复位”的问题。比如菜单关闭后主界面点不了按钮日志里看状态是不是还停在 opened列表滚动时菜单露出一条边看状态是不是错误地进入了 dragging。用日志验证状态机比肉眼盯着动画流畅度判断要高效得多。5. 侧滑菜单避坑指南五个我踩过的真实问题5.1 现象tableView 一动菜单也跟着露出白边现象主界面是 UITableView手指上下滚动列表菜单边缘也跟着露出一条白边松手后又弹回去。原因pan 手势和 scrollView 的 pan 手势同时响应gestureRecognizerShouldBegin没有拦截纵轴速度。解决在gestureRecognizerShouldBegin里比较 velocity 的横纵轴绝对值纵轴更大一律返回 NO把控制权完全交还给列表滚动。5.2 现象菜单打开后导航返回手势失效现象iOS 13 以后系统默认支持 UINavigationController 边缘返回手势侧滑菜单把导航控制器往右拖时经常先触发系统返回菜单滑不出来。原因UIScreenEdgePanGestureRecognizer本身也是一个 pan 手势两个手势竞争响应系统边缘手势的优先级更高。解决在手势 delegate 里判断otherGestureRecognizer是否为UIScreenEdgePanGestureRecognizer是则返回 NO 让系统手势先失败也可以只在菜单关闭状态下才允许边缘手势参与响应。5.3 现象菜单关闭后遮罩层还在拦截点击现象关掉菜单主界面按钮点不动点一下像是被什么东西挡住。原因maskView.hidden YES在动画 completion 里执行但userInteractionEnabled没同步复位alpha 为 0 的 view 仍然能接收事件。解决在closeMenuWithVelocity的 completion 里把maskView.userInteractionEnabled设为 NOhidden设为 YESopened 状态时再开启交互。5.4 现象键盘弹起时滑出菜单键盘盖住菜单内容现象搜索框聚焦时滑出菜单键盘不消失直接把菜单下半部分挡住。原因键盘是独立 window 的视图容器控制器的 transform 不会作用到键盘上菜单和键盘成了两个互不关联的层级。解决菜单手势 began 时立即调用[self.view endEditing:YES]先收键盘再让菜单完整滑出。5.5 现象iPad 横竖屏切换后菜单宽度溢出或缩进异常现象竖屏时候 280pt 的菜单宽度在横屏上显得太窄切换到横屏小尺寸窗口时菜单又超出屏幕。原因menuWidth 固定写死没有随屏幕宽度变化重新计算。解决在viewWillTransitionToSize:withTransitionCoordinator:里按宽高比重新计算 menuWidth并同步更新 menuController.view 的 frame 和 transform。6. 二次开发把单侧菜单升级成可配置的菜单框架6.1 支持左右双侧菜单在现有架构上加左右双侧本质是给容器增加一个方向枚举public enum XXSideMenuDirection { case left case right }左侧菜单的位移方向是正 x右侧菜单是负 x遮罩层的透明度映射方向也要反过来。手势识别器根据当前方向判断允许的 translation 正负拖拽过程中超出边界就夹逼在 0 到 menuWidth 之间。这块改动只涉及容器控制器和手势类的内部逻辑业务方接口不用变。6.2 菜单内容与业务解耦菜单里的 cell 不应该直接持有跳转逻辑。我会在MenuViewController里声明一个点击回调 blockproperty (nonatomic, copy) void (^menuItemSelected)(NSIndexPath *indexPath);业务方拿到回调事件后自己做页面跳转菜单组件只负责展示和交互不关心跳到哪里去。这样菜单可以复用、可以预演也可以接远程下发的菜单配置。6.3 交互式动画的进阶方向如果产品后续要求“菜单拖到一半松手能预览回弹效果”可以把手动动画替换成UIViewPropertyAnimator在拖动过程中实时推进动画进度。这个方向的前提是把 3.2 里的位移计算切到fractionComplete模式动画时间固定为 1 秒拿手指位移除以菜单宽度得到进度。从那以后我每次接侧滑菜单相关需求都会强制走一遍状态机划分、手势互斥表、遮罩交互复位这三步能省下一大半联调时间。希望这些经验和代码拆解帮到你。本文还有配套的精品资源点击获取
RELATED

相关推荐

PyTorch Tensor核心知识一文掌握:从张量创建到自动求导

PyTorch Tensor核心知识一文掌握:从张量创建到自动求导

1. 为什么第一站必须是张量如果你翻过几篇PyTorch的教程,大概率会看到一句话:“PyTorch的一切操作都是基于Tensor的。”这话不假,但初学者往往没意识到它到底有多重要。我自己的体会是:Tensor(张量)这个概念…

📅 2026/10/15 10:45:35
模运算与余数判重:可被K整除的最小整数问题解析

模运算与余数判重:可被K整除的最小整数问题解析

1. 从一道题看透模运算:为什么“可被 K 整除的最小整数”值得单独拎出来讲“可被 K 整除的最小整数”这道题,第一次看到的时候我差点直接上手暴力枚举。不就是从 1、11、111、1111 这样一直往上堆,直到能被 K 整除为止吗?写个循环…

📅 2026/10/15 10:45:35
caveman:极简轻量级工具集的“穴居人”哲学

caveman:极简轻量级工具集的“穴居人”哲学

“caveman”这个词第一次出现还是我翻开源代码仓库时看到的一个目录名,后来逐渐成了一套被我反反复复使用的内部代号。项目本身不是什么宏大工程,而是一个追求极致简单、强调原生能力的小工具集。现在很多项目动不动就引入整套框架,依赖树拉出…

📅 2026/10/15 10:40:30
MORE NEWS

更多资讯

📰

PushDeer 推送服务 gorush 的 HTTP API 测试指南:用 bat 与 test.json 验证多平台消息推送

后端消息路由移动开发物联网 【免费下载链接】pushdeer 开放源码的无App推送服务,iOS14扫码即用。亦支持快应用/iOS和Mac客户端、Android客户端、自制设备 项目地址: https://gitcode.com/gh_mirrors/pu/pushdeer 点击查看 免费下载 本指南面向部署了 P…

📰

万字长文介绍 AI 软件开发全链路实战:从 IDE 到 Agent 的 TaoToken 统一接入

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

运行OpenClaw智能自动化,养龙虾(OpenClaw)到底用什么电脑合适:TaoToken统一Key接入下的硬件选型清单

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

英飞凌 AURIX TC3xx SMU 安全管理单元

目录 TC3xx SMU 统一的故障管理 RT Recovery Timer SMU冗余设计 SMU系统集成 应用实例 TC3xx SMU key features: 1. 统一的故障管理; 2. 恢复计时器; 3. SMU双份设计,冗余设计; 统一的故障管理 SMU可以对每个…

📰

Path of Building:一个让你进游戏前把流放之路构建算明白的离线构建规划器

Path of Building:一个让你进游戏前把流放之路构建算明白的离线构建规划器 【免费下载链接】PathOfBuilding Offline build planner for Path of Exile. 项目地址: https://gitcode.com/GitHub_Trending/pa/PathOfBuilding 每次换玩法都要经历一遍洗点的代价…

📰

新职业落地:具身智能与智能体实验室建设方案深度解读

第八批新职业发布的消息出来那天,我正好在一所高校的电子信息学院参与实验室建设方案的评审。桌上摊着机械臂和移动机器人底盘的报价单,旁边是教务处长反复问的一个问题:具身智能、智能体这些新岗位到底落在哪里,我们的实验室应该…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬