iOS扇形菜单自定义控件:坐标计算与动画实战
2026/9/9 3:16:15 网站建设 项目流程

简介: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决定了扇形覆盖的区域。这两个参数不是随便写的,取决于主按钮在屏幕上的位置和菜单要朝哪个方向展开:

  • 主按钮在屏幕下方中央,菜单希望朝上展开,角度范围可以选.pi2 * .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里重算center

5.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旋转这些花活,稳扎稳打才能少踩坑。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询