ARTICLE DETAIL

资讯详情

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

iOS扇形菜单自定义控件:坐标计算与动画实战

iOS扇形菜单自定义控件:坐标计算与动画实战 简介iOS端扇形弹出按钮菜单的Xcode工程示例面向初中级iOS开发者重点演示自定义控件与交互动画的实现路径。项目以一个主按钮作为入口点击后通过UIButton事件绑定触发子菜单核心动画基于Core Animation框架的transform旋转属性完成扇形展开并结合UIView动画方法控制时长与节奏同时自定义视图布局以容纳多个子项。资源包共45个文件既包含Objective-C源文件.m/.h、界面布局.storyboard、属性列表.plist、图标素材.png也有Xcode工程配置.pbxproj等工作区文件整体仅59KB目录紧凑适合直接打开工程研读。已有678人学习下载。通过该工程可重点掌握按钮事件响应机制、旋转角度与锚点设置、动画曲线调校、菜单项触摸反馈以及多设备尺寸适配等关键技巧对理解UIButton、Core Animation组合使用和提升菜单交互设计能力均有参考价值也可作为自定义控件开发的入门模板。 做iOS开发这些年我发现自己经常在UI细节上反复折腾。去年做一款个人记账App时首页右下角需要一个“快速新增”的入口点击后要能快捷展开“记一笔、扫码、拍照、日历”几个功能。最开始图省事直接弹了系统ActionSheet用起来中规中矩但作为整个App最高频的操作入口总觉得少了点仪式感。后来干脆自己写了个按钮菜单点击主按钮周围动画弹出扇形菜单菜单项带一点弹簧效果依次散开再点一下或者点其他区域又沿着原路收回。效果出来之后整个首页的交互质感提升了一个档次。这个项目的核心其实就两件事一是把菜单项准确地摆到圆弧位置上二是把展开和收起的动画做自然。听起来简单真正实现时会遇到坐标系换算、动画时序、点击区域、遮挡层级一堆坑。这篇文章我就以自己重构过的这套“iOS按钮菜单”为例子把完整实现思路、核心代码和踩过的坑都写出来。适合两种人看一种是想给App加一个不那么普通的快捷菜单的iOS开发者另一种是想搞懂自定义控件中坐标计算和动画配合的初学者。1. 设计思路为什么要自己写一个扇形菜单1.1 常见菜单形态对比先聊聊方案选型。移动端上做快捷操作入口方案其实不少但各有取舍。我做过一个对比菜单形态信息承载量交互成本视觉表现典型场景ActionSheet高低普通分享、更多操作下拉菜单中低普通筛选、排序侧滑抽屉高中一般导航、设置悬浮按钮扇形菜单低3-6项低强快捷新增、高频操作ActionSheet和下拉菜单胜在实现简单系统组件几行代码就能调出来但问题也很明显它们都是从界面底部或顶部弹出的“二级界面”用户看一眼还需要做一次视觉跳转。扇形菜单最大的优势是操作路径极短——手指不用离开点击区域第一层就能看到所有可选项展开的动效还会给用户一种“这个App有用心做”的印象。1.2 扇形菜单的应用边界但注意扇形菜单不是一个万能控件它有非常明确的适用边界。适合的场景我总结下来有三类高频且数量少的快捷操作比如首页的“新建”、聊天页的“附加”、播放器页的“更多”3到6个入口已经是极限入口之间有视觉区分度的场景比如不同颜色、不同图标的工具栏移动端为主、强调单手操作的场景扇形菜单天生就是围着拇指位置展开的。不合适的情况也有如果菜单项超过8个、文案太长、需要给用户展示详细说明就别用扇形菜单了老老实实去用ActionSheet或者列表页。我做过的教训是曾经硬塞了7个菜单项进去视觉效果极其拥挤扇形半径被迫加大最远的按钮已经超出了拇指覆盖范围结果用户根本点不到。后来砍到5个体验立刻正常。2. 扇形布局的核心计算从圆心到坐标2.1 极坐标与屏幕坐标的转换扇形菜单的本质是把若干按钮以主按钮为圆心均匀分布在一个圆弧上。这其实就是一个非常经典的数学问题极坐标转直角坐标。屏幕上每个按钮的位置可以用圆心坐标centerX, centerY、半径r和角度theta表示。在iOS的UIView坐标系里原点在左上角x向右y向下目标点坐标为let x centerX r * cos(theta) let y centerY r * sin(theta)有同学会问iOS坐标系y轴向下三角函数的计算结果对不对答案是直接就对了。因为iOS的坐标系翻转后角度0度在正右方向、90度在正下方向角度增加的方向在视觉上是顺时针。这个顺时针效果正好符合大多数扇形菜单的交互直觉。如果觉得抽象可以把主按钮想象成表盘中心菜单项就是表盘上的刻度。你想要菜单往哪个方向展开就控制这个“角度范围”。2.2 参数设计与角度分配在代码里我一般用这几个参数控制扇形形态let itemCount 5 // 菜单项数量 let radius: CGFloat 120 // 按钮中心到主按钮中心的距离 let startAngle: CGFloat .pi * 0.5 // 起始角度 90度 let endAngle: CGFloat .pi * 1.5 // 结束角度 270度startAngle和endAngle决定了扇形覆盖的区域。这两个参数不是随便写的取决于主按钮在屏幕上的位置和菜单要朝哪个方向展开主按钮在屏幕下方中央菜单希望朝上展开角度范围可以选.pi到2 * .pi也就是180度到360度视觉上覆盖上半圆主按钮在屏幕右下角菜单希望朝左上方弹出角度范围可以选.pi * 0.75到.pi * 1.25这样的区间主按钮在屏幕中部想要一个完整的180度扇形就选.pi * 0.5到.pi * 1.5。每个菜单项的角度常见做法是均分let angle startAngle (endAngle - startAngle) * CGFloat(index) / CGFloat(itemCount - 1)注意这里用的是itemCount - 1这样保证第一个菜单项在startAngle位置、最后一个在endAngle位置。如果不想菜单项贴着边界也可以用(index 1) / (itemCount 1)的分配方式视觉上留白更多更柔和。这部分有点纯数学但这是整个组件的地基。只要这个换算搞清楚了后面所有代码都是顺水推舟的事。3. 完整实现一个可复用的扇形按钮菜单3.1 组件结构与按钮初始化我最终实现的FanMenuView主要职责有四个管理弹层遮罩、摆放主按钮、生成菜单按钮、播放展开收起动画。结构上我选择让它全屏铺开frame等于屏幕尺寸这样能顺手处理“点击其他区域收起”的逻辑。核心代码结构如下final class FanMenuView: UIView { private var menuButtons: [UIButton] [] private var maskButton: UIButton! private let itemCount: Int private let radius: CGFloat private let startAngle: CGFloat private let endAngle: CGFloat private var isExpanded false private var isAnimating false var onItemTap: ((Int) - Void)? private lazy var mainButton: UIButton { let button UIButton(type: .system) button.backgroundColor .systemBlue button.tintColor .white button.layer.cornerRadius 28 button.setImage(UIImage(systemName: plus), for: .normal) button.frame CGRect(x: 0, y: 0, width: 56, height: 56) button.addTarget(self, action: #selector(handleMainTap), for: .touchUpInside) return button }() init(itemCount: Int, radius: CGFloat 120, startAngle: CGFloat .pi * 0.5, endAngle: CGFloat .pi * 1.5) { self.itemCount itemCount self.radius radius self.startAngle startAngle self.endAngle endAngle super.init(frame: UIScreen.main.bounds) setupSubviews() } }这里有个细节初始化时我直接把frame设成了UIScreen.main.bounds而不是用约束固定。因为扇形布局本身就是绝对定位frame方案最直观也方便在动画里直接改center不会和AutoLayout的约束打架。生成菜单按钮的代码核心就是循环创建按钮并计算位置private func setupSubviews() { // 遮罩最先添加保证在所有按钮的最底层 maskButton UIButton(type: .custom) maskButton.frame bounds maskButton.backgroundColor .clear maskButton.addTarget(self, action: #selector(handleMaskTap), for: .touchUpInside) maskButton.isHidden true addSubview(maskButton) let centerX bounds.width - 70 let centerY bounds.height - 120 mainButton.center CGPoint(x: centerX, y: centerY) addSubview(mainButton) let colors: [UIColor] [.systemOrange, .systemGreen, .systemPink, .systemPurple, .systemTeal] for index in 0..itemCount { let button UIButton(type: .system) button.tag index button.backgroundColor colors[index % colors.count] button.layer.cornerRadius 25 button.frame CGRect(x: 0, y: 0, width: 50, height: 50) button.center mainButton.center button.alpha 0 button.transform CGAffineTransform(scaleX: 0.4, y: 0.4) button.addTarget(self, action: #selector(handleItemTap(_:)), for: .touchUpInside) addSubview(button) menuButtons.append(button) } }注意两个细节菜单按钮初始状态是透明且缩小到0.4倍并且center先放在主按钮位置上这样动画展开时才会有“从按钮中心散开”的视觉效果。遮罩button先addSubview确保它在视图层级的最底层不会拦截菜单按钮的点击。3.2 展开与收起动画展开动画我用的是UIView的spring动画每个菜单项加了一点延迟这样视觉上是一次错峰弹出而不是所有按钮同时飞出来func expand() { guard !isExpanded, !isAnimating else { return } isExpanded true isAnimating true maskButton.isHidden false for (index, button) in menuButtons.enumerated() { let targetAngle angle(for: index) let targetCenter CGPoint( x: mainButton.center.x radius * cos(targetAngle), y: mainButton.center.y radius * sin(targetAngle) ) UIView.animate( withDuration: 0.4, delay: Double(index) * 0.045, usingSpringWithDamping: 0.72, initialSpringVelocity: 0.6, options: [.curveEaseOut] ) { button.alpha 1 button.transform .identity button.center targetCenter } } UIView.animate(withDuration: 0.25) { self.mainButton.transform CGAffineTransform(rotationAngle: .pi / 4) } DispatchQueue.main.asyncAfter(deadline: .now() 0.5) { self.isAnimating false } }收起动画基本是展开的逆过程但有两个经验一是动画时长要更短一些收起应该是干脆的不能像展开那样拖沓二是可以让最后展开的菜单项先收也就是把delay顺序反过来视觉上会产生一种“花瓣向中心收拢”的效果func collapse() { guard isExpanded, !isAnimating else { return } isExpanded false isAnimating true maskButton.isHidden true for (index, button) in menuButtons.enumerated().reversed() { UIView.animate( withDuration: 0.25, delay: Double(itemCount - 1 - index) * 0.03, options: [.curveEaseIn] ) { button.alpha 0 button.transform CGAffineTransform(scaleX: 0.4, y: 0.4) button.center self.mainButton.center } } UIView.animate(withDuration: 0.2) { self.mainButton.transform .identity } DispatchQueue.main.asyncAfter(deadline: .now() 0.3) { self.isAnimating false } }3.3 主按钮旋转与状态管理主按钮在展开时旋转45度变成叉号是这类菜单的标配交互。这里我用的是mainButton.transform需要小心的是如果你之前已经给主按钮设置过transform比如加过scale动画再取rotationAngle时容易叠加出奇怪效果。我的做法是保持一个原则主按钮的transform只由这一个动画控制其他任何地方都不能随便改。展开状态下再点主按钮要能收起收起状态下点主按钮要展开。这个逻辑非常简单但容易出bug的地方在于快速连点。用户手速快的时候展开动画还没结束就点了收起此时isAnimating为true直接return就能避免动画状态错乱。这个小锁是整个组件的安全保险一定不要省。组件对外的交互只有两个回调一个是点击了哪个菜单项onItemTap一个是点击遮罩/主按钮收起后的状态变化。点击菜单项后我一般会做一个“先执行动画收起再回调”的设计避免菜单还开着就去跳转页面objc private func handleItemTap(_ button: UIButton) { let index button.tag collapse() DispatchQueue.main.asyncAfter(deadline: .now() 0.3) { self.onItemTap?(index) } }这样外部调用方不需要关心动画时序拿到回调的时候菜单已经收好了页面跳转不会被打断。4. 动画细节与交互体验打磨4.1 三个让动画更高级的小技巧这个组件做完之后我反复调了几轮动画参数总结出三个非常关键的细节。第一错峰延迟是灵魂。没有延时的扇形展开所有按钮同时飞出去看起来像爆炸完全没有“扇形”的层次感。我用的0.045秒步进是经过验证的5个菜单项最后一个比第一个晚约0.18秒正好是让人能逐一眼扫过所有按钮的节奏。如果菜单项少3个延迟可以调到0.06秒如果菜单项多6个以上反而要适当缩短步进不然整体展开耗时太久。第二spring参数不是越大越好。UIView的usingSpringWithDamping参数0.72左右是我实测比较舒适的值。参数太小比如0.3按钮来回弹好几下虽然视觉效果很“活跃”但用户会明显觉得按钮在晃尤其是有文字或图标的时候晃动会让识别困难。参数太大比如0.9又几乎没有弹性看起来干巴巴的。0.6到0.8是一个安全区间。第三收起动画要比展开快。展开是主角登场可以稍微拖一点收起是谢幕必须干脆。我实际体验下来展开0.4秒、收起0.25秒的组合节奏感最好。很多初学动画的同学只关注展开动画忽略收起动画结果收起时那个慢慢吞吞的动画严重拖累操作效率。4.2 点击区域、遮罩与误触防护菜单按钮本身50x50视觉上够大了但Apple HIG建议的点击目标最小是44x44考虑到扇形边缘位置手指容易滑偏我实际把按钮的frame做到了56x56同时把图片和文字都控制在28pt以内既保证点击面积又不让按钮显得笨重。遮罩层的处理同样重要。遮罩只有当菜单展开时才显示并且我会把它放在所有菜单按钮的下面一层。为什么要加遮罩因为用户点菜单外的区域时需要有一个统一的“收起”响应点。没有遮罩的话你得在ViewController里写touchesBegan或者给每个无关view加点击事件麻烦且容易漏。用全屏透明的button做遮罩一行addTarget就解决了还能顺便处理菜单展开时禁止用户操作底层页面的问题。5. 踩坑记录扇形菜单的常见问题与解决思路5.1 问题速查表这部分整理一下我做这个组件时遇到的几个典型问题症状原因解决方案菜单按钮出现在画布左上角初始化时bounds还没确定计算位置用到了错误的bounds把按钮位置相关计算放到layoutSubviews里或确保先设置frame再执行setup展开动画直接闪到目标位置没有任何过渡delay和duration参数传成了0或代码在viewDidLoad里执行时view还未布局确认动画是在viewDidAppear之后触发先调用layoutIfNeeded快速连点导致菜单卡在半个展开状态没有动画状态锁加isAnimating动画中直接return在主窗口window上添加时按钮被NavigationBar遮挡层级低于导航栏把菜单添加到keyWindow并保证zPosition高于导航栏展开时点击边缘菜单项无反应点击区域太小或者遮罩button盖住了菜单按钮检查addSubview顺序把遮罩放在最底层扩大按钮frame横竖屏旋转后菜单位置错乱frame写死了屏幕尺寸监听旋转事件在layoutSubviews里重算center5.2 我在三个真实场景里的排查过程第一个是位置错乱。第一次实现时我在ViewController的viewDidLoad里创建了FanMenuView并调用了setup。结果菜单按钮全部跑到了屏幕左上角。排查后发现viewDidLoad时self.view的bounds还不是最终尺寸在iPhone 14 Pro上安全区变化时这个时机更明显。解决方法是把一切依赖bounds的计算挪到layoutSubviews里或者至少在viewDidLayoutSubviews之后再触发。如果你的组件要复用强烈建议在layoutSubviews里重算所有按钮位置否则换一台屏幕尺寸不同的设备就可能出问题。第二个是遮罩挡住菜单按钮。这个问题很隐蔽我一开始用UIButton做遮罩时直接在setup里把它addSubview到了最后也就是最上层。视觉上遮罩是透明的菜单按钮都能看到点击却全部失效。排查了很久才发现透明button虽然看不见但确实在上层拦截了所有touch事件。解决方式很简单把遮罩的addSubview放在所有菜单按钮之前让它在视图层级的最底层。第三个是团队里其他同事反馈“展开速度有点晕”。头晕感主要来自spring阻尼太小按钮回弹幅度太大加上错峰延迟太长菜单展开动画前后持续了近1秒。用户盯着这些移动很快的元素自然会觉得累。我把展开时长从0.5秒压到0.4秒把步进延迟从0.06秒缩到0.045秒阻尼从0.55调到0.72再让同事体验反馈好很多。动画调整不要靠猜要找几个人实测视觉舒适度这个东西主观性非常强。5.3 悬浮按钮的安全区与旋转适配把主按钮固定在右下角的方案在带Home Indicator的机型上要注意别和底部横条重叠。我demo中用的是bounds.height - 120在真机上需要结合safeAreaInsets做偏移。更稳妥的写法是在layoutSubviews里重新计算override func layoutSubviews() { super.layoutSubviews() let safeBottom safeAreaInsets.bottom mainButton.center CGPoint( x: bounds.width - 70, y: bounds.height - safeBottom - 80 ) }如果你的App需要支持横屏那整个扇形布局都建议在layoutSubviews里重算因为bounds一变所有按钮的绝对坐标都需要同步更新。6. 这个组件做完之后我的几点实际体会这次写的按钮菜单后来抽成了团队组件库里的一个独立控件一个初始化方法加一个回调哪里需要哪里用。到现在已经上线了两个版本数据统计里这个按钮菜单的使用频率一直很高。最后分享一个个人经验自定义控件最怕的不是技术难而是过度设计。扇形菜单的边界一定要想清楚菜单项数量严格控制、动画速度反复测试、遮罩层级做好这个小组件才能真正变成加分项而不是花架子。尤其是动画时长和弹性参数千万不能凭感觉一把梭找一个真实的页面反复点、反复试让旁边的人也点一点最后调出来的节奏一定是多数人觉得舒服的。如果你也正在做类似的东西先把坐标计算、状态锁、遮罩层级这几个核心点跑通再考虑加模糊背景、3D旋转这些花活稳扎稳打才能少踩坑。本文还有配套的精品资源点击获取
返回列表