ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

iOS侧滑菜单栏:抽屉导航与列表左滑的手势冲突解决

iOS侧滑菜单栏:抽屉导航与列表左滑的手势冲突解决 简介面向iOS开发者的侧滑菜单栏示例工程演示了通过点击按钮移动自身视图以呼出侧滑菜单的完整机制适合刚接触自定义UI交互的初学者学习参考。压缩包共25个文件、约36KB以Objective-C源文件为主涵盖.m/.h实现与头文件、plist工程配置、strings本地化字符串以及Xcode项目工程文件结构清晰便于直接编译运行。源码中提供LeftView侧滑视图、CenterView主内容视图、MainViewController主控制器和CustomTabBar自定义标签栏覆盖视图层级搭建、按钮事件处理、动画过渡、Auto Layout约束以及菜单开关状态管理等关键环节可帮助读者完整理解从按钮触发到菜单滑出/收回的交互逻辑。目前已有199人学习/浏览适合需要快速实现侧滑菜单并希望掌握底层实现细节的iOS开发者。1. iOS 侧滑菜单栏抽屉导航和列表左滑是两回事先分清再动手做 iOS 客户端的人应该都收到过这样的需求首页加个侧滑菜单栏。这个词在真实项目里其实指两件事一种是类似微信、QQ 里那种从屏幕左侧滑出的抽屉式导航栏另一种是聊天列表、邮箱列表里左滑之后出现的删除、置顶按钮。我见过团队把两个需求做成了一锅粥最后手势冲突、返回手势失效、cell 误触全来了。这篇文章把两种侧滑菜单栏怎么选、怎么落地讲透重点放在抽屉式侧滑菜单的容器设计和手势排障上目标是让一个新手能照着搭出能上线的骨架让熟手直接抄参数和排查思路。把 iOS 侧滑菜单栏拆开看真正的难点根本不在 UI在手势事件怎么分发。2. 侧滑菜单栏的三条实现路线与手势状态机选对容器后面少踩一半坑2.1 路线对比第三方库、视图平移、自定义容器控制器先说结论。侧滑菜单栏的工程实现常见做法无非三条路直接用现成的开源库、用 UIView 平移去改主视图位置、用 UIViewController 容器把菜单和内容都挂进一个父控制器。我一般不建议把老牌抽屉库直接塞进新工程这类库多数停更好几年了iOS 13 之后的 modal、多窗口、暗色模式都会让你被迫去改库源码。快速验证 demo 时拿来跑一下没问题生产环境一旦碰上问题你很难救。第二条路最轻给主内容视图做 transform 或约束偏移菜单视图盖在上面加一个 shadow 和一个半透明遮罩就算完了。优点是代码量小、改起来快缺点是状态栏样式、控制器生命周期、横竖屏切换全都要自己兜当主内容是 UINavigationController 时会碰到我现在到底在哪个控制器里的黑匣子问题。我做过一个需求预算只有两天选了平移方案结果 iOS 15 之后根控制器旋转后菜单宽度不对还得回头去改手势计算实际省的时间全赔回去了。真正适合长期维护的是第三条UIViewController 容器方案。把菜单控制器和内容导航控制器都作为 child view controller 挂到同一个父控制器下父控制器只负责两件事摆放两个子控制器的 view以及把手势回调翻译成菜单打开、关闭的动画。子控制器的生命周期、状态栏、转屏动作都能按系统的规矩走。这里有个容易写错的顺序addChild 和 didMove(toParent:) 必须成对出现先 addChild 再加 view 再 didMove移除子控制器时是先 willMove 再 removeFromParent。顺序搞反子控制器的 viewWillAppear 和 viewDidAppear 会乱这在侧滑菜单里表现为菜单第一次打开时空白第二次才正常是典型的玄学 bug。方案成本主要风险适合场景开源抽屉库最低年久失修、手势细节不可控内部工具、demoUIView 平移中等生命周期与状态栏不可控单页面、无导航栈UIViewController 容器中等偏高初始代码多后续维护稳生产环境导航骨架2.2 手势状态机从 began 到 failed侧滑菜单的进度怎么算容器方案里的核心就是手势。系统提供 UIScreenEdgePanGestureRecognizer边缘拖动和 UIPanGestureRecognizer全屏拖动。做左侧滑菜单推荐用边缘手势监听屏幕左缘这样不会影响列表区域在页面中间的滚动。这里先提醒一句一个 UIScreenEdgePanGestureRecognizer 实例只能设置一个方向同时需要左边和右边两个菜单时要创建两个实例分别绑定不要在同一个实例上改 edges 属性来回切系统在运行时切换方向经常失灵。手势处理函数的骨架一般是这样真实工程里我建议把它抽成独立类型方便替换动画enum PanResult { case idle case shouldOpen case shouldClose } func handle(pan: UIPanGestureRecognizer, menuWidth: CGFloat) - PanResult { switch pan.state { case .began: return .idle case .changed: // 把手指横向位移换算成 0~1 的进度 let progress min(max(pan.translation(in: pan.view).x / menuWidth, 0), 1) updateMenu(progress: progress) return .idle case .ended, .cancelled: let translationX pan.translation(in: pan.view).x let velocityX pan.velocity(in: pan.view).x if velocityX 800 || translationX menuWidth * 0.35 { return .shouldOpen } return .shouldClose case .failed: return .idle unknown default: return .idle } }这段代码的逻辑不复杂但参数值得说清楚。began 阶段只做复位不要在 began 里立刻改变菜单 frame否则每次抬手后再次按下会闪一下changed 阶段用 translation 而不是 gesture location因为 translation 是相对手势开始位置的位移天然适合做跟手进度。ended 阶段综合位移和速度两个因素判断方向速度的优先级更高——手速快的用户常常位移不到一半就松手此时只看位移会误判为关闭所以要加 800 这个速度阈值。这个阈值定多少按产品的手感调常见取值是菜单宽度的 30%~40%配合 800~1000 点每秒的速度阈值基本不会被系统先识别成别的操作。还有一个和手势相关的细节UIScreenEdgePanGestureRecognizer 的触发范围由系统内部管理外部没法直接修改。如果你的菜单入口不在屏幕左缘、而在页面里某个按钮上或者产品要求从边缘 24pt 内才能触发边缘手势就满足不了得换 UIPanGestureRecognizer 全屏挂再自己写起点是否在左侧 24pt 内、横向速度大于纵向速度的判断。这个方案我会在第 5.3 节展开因为它和列表左滑操作会打架。3. 手写一个侧滑菜单栏容器约束布局、边缘手势与弹簧动画参数3.1 先搭父控制器两个子控制器一个塞进导航一个放在左侧屏外我从零写一个最小可运行的 Swift 版容器。它作为 UI 的根控制器替代原来的 rootViewController。类的职责只有三个挂子控制器、摆视图、响应手势。final class SideMenuContainerViewController: UIViewController { private let menuVC: UIViewController private let contentNav: UINavigationController private var menuLeadingConstraint: NSLayoutConstraint! private var menuWidth: CGFloat 280 private let dimmingView UIView() init(menu: UIViewController, content: UINavigationController) { self.menuVC menu self.contentNav content super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError(用代码初始化) } override func viewDidLoad() { super.viewDidLoad() addChild(contentNav) view.addSubview(contentNav.view) contentNav.view.frame view.bounds contentNav.didMove(toParent: self) addChild(menuVC) view.addSubview(menuVC.view) menuVC.view.translatesAutoresizingMaskIntoConstraints false let leading menuVC.view.leadingAnchor.constraint( equalTo: view.leadingAnchor, constant: -menuWidth) menuLeadingConstraint leading NSLayoutConstraint.activate([ leading, menuVC.view.topAnchor.constraint(equalTo: view.topAnchor), menuVC.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), menuVC.view.widthAnchor.constraint(equalToConstant: menuWidth) ]) menuVC.didMove(toParent: self) setupDimmingView() setupGestures() } }要说明的是addChild 和 didMove(toParent:) 必须成对顺序不能反。menuVC 的 leading 约束初始值是 -menuWidth意味着菜单完整藏在屏幕左侧外约束驱动视图移动比直接改 frame 更适合旋转和 iPad 分屏。menuWidth 建议用 min(屏幕宽度 * 0.8, 320)这是一线工程里最常见的取值超过屏宽 80% 时用户单手够不到菜单另一边超过 320pt 在 iPad 上会显得内容稀薄。注意这里的 menuWidth 先按 280 写死生产环境建议在 viewDidLayoutSubviews 里根据当前 view.bounds 重新计算第 4.5 节会讲原因。3.2 边缘手势配合遮罩点遮罩关闭、跟手进度、动画回弹参数接下来给容器补上两个交互从屏幕左缘滑出菜单点击半透明遮罩关闭菜单。private func setupDimmingView() { dimmingView.backgroundColor UIColor.black.withAlphaComponent(0.4) dimmingView.alpha 0 dimmingView.frame view.bounds dimmingView.autoresizingMask [.flexibleWidth, .flexibleHeight] view.insertSubview(dimmingView, belowSubview: menuVC.view) } private var menuEdgePanGesture: UIScreenEdgePanGestureRecognizer? private func setupGestures() { let edgePan UIScreenEdgePanGestureRecognizer( target: self, action: #selector(handleEdgePan(_:))) edgePan.edges .left view.addGestureRecognizer(edgePan) menuEdgePanGesture edgePan let tap UITapGestureRecognizer(target: self, action: #selector(closeMenu)) dimmingView.addGestureRecognizer(tap) } objc private func handleEdgePan(_ pan: UIScreenEdgePanGestureRecognizer) { guard menuLeadingConstraint.constant 0 else { return } let translationX pan.translation(in: view).x switch pan.state { case .changed: let progress min(max(translationX / menuWidth, 0), 1) menuLeadingConstraint.constant -menuWidth * (1 - progress) dimmingView.alpha 0.4 * progress view.layoutIfNeeded() case .ended, .cancelled: let shouldOpen (pan.velocity(in: view).x 800) || (translationX menuWidth * 0.35) if shouldOpen { openMenu() } else { closeMenu() } default: break } } private func openMenu() { setMenuOpen(true) } private func closeMenu() { setMenuOpen(false) } private func setMenuOpen(_ open: Bool) { UIView.animate(withDuration: 0.28, delay: 0, usingSpringWithDamping: 0.86, initialSpringVelocity: 0.5, options: [.curveEaseOut]) { self.menuLeadingConstraint.constant open ? 0 : -self.menuWidth self.dimmingView.alpha open ? 0.4 : 0 self.view.layoutIfNeeded() } }这个版本的边缘手势只负责把菜单从关闭状态拖出来打开之后靠点遮罩或菜单项关闭避免打开状态下再从边缘拖动这种拆了又装、装了又拆的多余状态。changed 里每次更新约束和 alpha 后必须调 layoutIfNeeded否则约束变更在同一个 runloop 里不会立刻生效手势会一卡一卡像漏电的电梯。手势结束时立刻落地动画不要在 changed 里提前触发完整动画否则会出现动画没跑完又被手指拖走的抖动。参数部分是我在多个 App 里调过的推荐值可以按设计稿微调参数推荐值说明菜单宽度min(屏宽 * 0.8, 320)兼顾单手操作和内容展示遮罩透明度0.35 ~ 0.45低于 0.3 层级感弱高于 0.5 太压抑动画时长0.25 ~ 0.35s和系统 push 动画时长一个量级别超过 0.4弹簧阻尼0.85 ~ 0.95回弹轻快太小的阻尼会左右甩速度阈值800 ~ 1000 pt/s补偿位移不足强制完成打开这组参数不是玄学是照着系统 push/pop 动画的节奏校准的。0.28 秒这个值交互上给人跟手又干脆的感觉如果你更想要 iOS 控制中心那种拖拽粘性可以改用 UISpringTimingParameters 自己定制而不是只调 damping。3.3 两个容易漏的细节状态栏归属和安全区menuVC 作为 child 存在时状态栏样式默认仍由父控制器读出结果就是菜单明明是深色背景状态栏还保留主页面的浅色样式。要把状态栏交给 menuVC容器里重写这两个属性override var childForStatusBarStyle: UIViewController? { menuVC } override var childForStatusBarHidden: UIViewController? { menuVC }菜单内容还要避开安全区。如果 menuVC 是普通 ViewController第一个 cell 需要把 top inset 加上 safeAreaInsets.top刘海屏和旧设备的 inset 不一样用固定值写死会在切换设备时出现一条空白或刘海挡住内容。菜单里点击某个 item 后标准做法是先 closeMenu 完成再根据路由去 push/present不要在菜单展开动画没结束时叠加第二次转场两层动画叠一起会闪屏用户容易误触。4. 侧滑菜单栏踩坑排查手势互抢、返回误触、掉帧与真机调试的 5 个现场4.1 现象左滑屏幕边缘菜单没打开上一级页面先滑回去了原因抽屉容器作为根控制器、内容区是 UINavigationController 时系统自带的 interactivePopGestureRecognizer侧滑返回默认也在工作。你在二级页面上从左缘向右滑系统返回手势和你的抽屉边缘手势同时响应两个 UIScreenEdgePanGestureRecognizer 竞争系统往往判定返回优先级更高页面就滑回去了。解决让抽屉手势只响应根页面。实现上通过 UINavigationControllerDelegate 判断导航栈深度栈里只有一个控制器时才开启抽屉手势extension SideMenuContainerViewController: UINavigationControllerDelegate { func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) { let canOpenMenu navigationController.viewControllers.count 1 menuEdgePanGesture?.isEnabled canOpenMenu } }在 init 里要记得设置 contentNav.delegate self。这个方案不改业务页面的代码push 和 pop 完成就会自动切换状态。如果把功能做在 UIViewController 的扩展里要注意是不是同时对多个实例生效容易出现一个开关控制所有页面的坑。4.2 现象菜单关闭后点击原来被遮罩盖住的位置却点穿了原因遮罩 view 的 alpha 变成 0 后它仍然在 hit-test 范围内。触摸会先命中遮罩本身而不是下面的按钮如果遮罩上挂了点击手势事件就被它吞掉底层页面收不到。解决关闭动画完成后把遮罩设为 hiddenhidden 的视图不参与 hit-test打开时再取消 hidden。注意别在动画一开始就 hidden那样关闭动画的渐隐效果就没了要在动画 completion 里处理UIView.animate(withDuration: 0.28) { self.dimmingView.alpha 0 } completion: { _ in self.dimmingView.isHidden true }4.3 现象低内存机型滑菜单时掉帧边缘出现白边或黑块原因菜单视图上同时开着 shadow 和 cornerRadius滑动时每帧都在触发离屏渲染加上约束动画里频繁调 layoutIfNeeded低内存机型就会掉帧。这个问题在 iPhone 8 这一代设备上非常明显菜单是深色背景时边缘出现一道黑边其实就是阴影渲染跟不上。解决给阴影写死 shadowPath内容视图的圆角用 masksToBounds 裁剪避免系统每帧重新计算阴影形状。更推荐把阴影和圆角分层外层 view 只画阴影内层 view 负责裁剪圆角滑动时只动 transform不触发 shadow 重绘。想确认是不是离屏渲染用 Instruments 的 Core Animation 模板看 Color Offscreen-Rendered 高亮区域最直观。4.4 现象iOS 17 真机装包后侧滑手势没反应Xcode 提示需要开发者模式原因从 iOS 16 开始真机调试默认关闭开发者模式首次连接 Xcode 会弹 Developer Mode Required。这不是代码问题却最像代码问题很多人在手势代码里排查几个小时最后发现环境没开。解决真机上到设置 - 隐私与安全性 - 开发者模式里打开系统会要求重启重启后再跑一次。这个坑建议团队新人入职时先讲能省一个上午。开发者模式仅影响调试和侧载不影响 App Store 分发不用有安全顾虑。4.5 现象iPad 旋转后菜单宽度不对弹出位置偏移原因menuWidth 如果初始化时用 UIScreen.main.bounds 算了一次旋转后不会自动更新横屏下本来 320pt 的菜单会显得短一截。多窗口模式下 UIScreen.main.bounds 也不等于窗口 bounds这个写法已经过时。解决在容器的 viewDidLayoutSubviews 里重新计算宽度并更新 menuVC 的宽度约束同时保证 menuLeadingConstraint 在 open 状态下保持 0。旋转场景建议加一行 debug 日志把 view.bounds 和约束值打出来对比比肉眼判断快override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() let newWidth min(view.bounds.width * 0.8, 320) if abs(newWidth - menuWidth) 0.5 { menuWidth newWidth // 更新 menuVC.view 的 width 约束 } }这五个现场是我压箱底的排查清单。前两条最难排查因为它们不报错只表现为不好用一旦出现优先怀疑手势竞争和命中测试而不是先调动画。5. 列表左滑菜单怎么接系统 Swipe Actions 的边界与抽屉菜单的共存前面几章讲的都是抽屉式侧滑菜单栏。但ios 侧滑菜单栏搜索词里还有一大类需求是列表或者会话侧滑操作这类操作在 iOS 11 以后有系统标准答案先讲标准做法。5.1 trailingSwipeActionsConfiguration 最小实现一个参数决定删除误触率在 UITableView 上做左滑菜单最正规的是实现 UITableViewDelegate 的两个方法之一trailing 表示从右往左滑leading 表示从左往右滑。最小实现长这样func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) - UISwipeActionsConfiguration? { let archive UIContextualAction(style: .normal, title: 归档) { _, _, completion in // 归档逻辑 completion(true) } archive.backgroundColor .systemBlue let delete UIContextualAction(style: .destructive, title: 删除) { [weak self] _, _, completion in self?.deleteRow(at: indexPath) completion(true) } delete.backgroundColor .systemRed let config UISwipeActionsConfiguration(actions: [archive, delete]) config.performsFirstActionWithFullSwipe false return config }这里两个细节很关键。actions 数组靠前的按钮在 trailing 场景下显示在最靠右也就是手指先碰到的位置所以把次要操作放前边、删除放后边能大幅降低本想按归档却删掉的误触率。performsFirstActionWithFullSwipe 默认是 true用户一下滑到底就会直接触发第一个 action如果第一个操作是删除必须关掉它这是血泪教训换来的参数。UIContextualAction 的 style 分 .normal 和 .destructivedestructive 会得到系统红色背景normal 默认灰色可以自定义 backgroundColor。点击按钮后 completion(true) 会让 cell 收回未滑动状态如果业务处理失败想保持展开就传 false 或先不调用。按钮 title 和 image 可以并存但中文 title 太长会挤压相邻按钮宽度建议 icon 加两个字以内。5.2 按钮超过系统的展示上限时把多余操作收进更多弹窗系统最多同时展示 4 个按钮超过 4 个时多余项会自动合到更多里但更多点击后的行为完全由你接管。生产环境我更推荐只放 2 个按钮加一个更多操作一多全展开用户的视觉负担很大而更多用 UIAlertController 的 actionSheet 呈现刚好承载备注、复制、举报这类低频操作let more UIContextualAction(style: .normal, title: 更多) { [weak self] _, _, completion in let alert UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) alert.addAction(UIAlertAction(title: 复制链接, style: .default)) alert.addAction(UIAlertAction(title: 举报, style: .destructive)) alert.addAction(UIAlertAction(title: 取消, style: .cancel)) self?.present(alert, animated: true) completion(true) }弹 actionSheet 时注意 iPad 上要设置 popoverPresentationController?.sourceView否则会崩。如果你觉得系统 Swipe Actions 不够灵活想自己写 cell 的左滑按钮容器边界在于你要自己处理手势冲突、多点触控、滑动后的复位动画iOS 11 之后系统方案几乎覆盖了所有列表侧滑需求我建议非必要不自研省下的时间去打磨抽屉菜单更值。5.3 列表左滑和抽屉侧滑怎么共存从最左缘开始的滑动手势怎么分流两种侧滑在同一屏幕上相遇时最经典的打架场景是页面是抽屉容器的根页面里面又套了一个列表用户从屏幕最左侧往右滑一个 cell系统会认为这是边缘手势该打开抽屉了列表的 leading 操作就永远触发不了。我的处理套路是抽屉菜单的边缘手势只认从屏幕最左缘开始并且横向速度较快的滑动。列表 cell 的 leading 手势本身由 UITableView 内部管理优先级低于边缘手势想让两边都活着工程上最稳的是让抽屉只在真正的菜单层级出现比如首页根控制器进到详情、列表这类二级页面后抽屉边缘手势直接禁用左边全交给系统返回手势。这是第 4.1 节导航代理策略的延伸。如果产品要求在同一个根页面同时保留抽屉和列表左滑就只能把抽屉从边缘手势换成全屏平移手势然后在 gestureRecognizerShouldBegin 里精确限制触发条件func gestureRecognizerShouldBegin(_ gesture: UIPanGestureRecognizer) - Bool { let location gesture.location(in: view) let velocity gesture.velocity(in: view) let horizontal abs(velocity.x) abs(velocity.y) return location.x 24 horizontal !menuIsOpen }这个24pt 速度方向判断能在不牺牲列表滚动的情况下保住抽屉入口。注意还要在手势 delegate 里同时实现 shouldRecognizeSimultaneouslyWith返回 false避免抽屉手势和 cell 的滑动手势同时驱动两个动画。这几个条件组合起来用户从最左缘起手势且明显横拉时打开抽屉其余情况都放给列表。6. 侧滑菜单栏上线前把手势写进 XCUITest 回归用例6.1 用 XCUITest 验证边缘滑动 40% 能打开菜单这个验收标准侧滑菜单的功能回归手动测容易漏因为手势没有按钮那样明确的点击目标。我习惯在 UI test target 里写一条用例从屏幕最左缘中点向右拖到约一半屏幕断言菜单视图出现。用坐标系的 press(forDuration:thenDragTo:) 模拟连续拖动比 swipeRight 更接近真实手势func testDrawerOpensFromLeftEdge() { let app XCUIApplication() app.launch() app.otherElements[home_content].tap() let start app.coordinate(withNormalizedOffset: CGVector(dx: 0.02, dy: 0.5)) let end app.coordinate(withNormalizedOffset: CGVector(dx: 0.45, dy: 0.5)) start.press(forDuration: 0.05, thenDragTo: end) let menu app.otherElements[side_menu_container] XCTAssertTrue(menu.waitForExistence(timeout: 2)) }为了让元素可定位给菜单容器 view 设置 accessibilityIdentifier。press(forDuration:) 的持续时间很关键太短会被系统识别成轻扫导致动画直接跳转一般 0.05~0.1 秒最接近人手拖动。这个用例跑在 CI 上能拦截大部分手势识别范围被改窄的回归。6.2 上线前用触达宽度和 VoiceOver 过一遍我最后一道工序不是跑单元测试而是打开 Xcode 的 View Debugger 检查菜单 item 的高度。Apple HIG 要求可点击区域至少 44x44pt侧滑菜单里的 cell 很容易只做到视觉高度 40点击热区也是 40小屏手机上就成了精致但难点。给菜单 cell 的 heightForRowAt 统一返回 44 以上必要时用最小高度约束兜底。辅助功能方面遮罩 view 默认会被 VoiceOver 读成无标签元素菜单打开时读屏用户会迷失方向。做法是给遮罩设置 accessibilityLabel 为关闭菜单菜单项按顺序组成一个 accessibilityContainer。这个验证不需要额外工具用 Xcode 自带的 Accessibility Inspector 就能走查。我修过一次因为瞎调弹簧阻尼参数、导致视觉团队反复不满意而返工的翻车现场之后就把参数全部收敛到容器工具类里页面层只传路由不碰动画参数。配合自动化用例侧滑菜单栏这类交互单点维护成本真的不高。希望帮到你。本文还有配套的精品资源点击获取
返回列表