简介:一份针对iOS开发者的实战型资源包,聚焦浮动泡泡交互功能,涵盖自定义视图绘制、动画驱动、碰撞检测三大核心模块,适合具备基础UIKit知识、想进阶动画与交互设计的中级开发者。压缩包共24个文件,以Xcode工程源码为主体,包含Objective-C源文件(.m/.h)用于绘制泡泡与碰撞逻辑,Storyboard和plist负责界面布局与工程配置,另有PNG图片素材和一段mov演示视频辅助理解,整体大小约7.34MB,结构清晰便于按工程目录查看。目前已有695人学习下载。其中包含完整的SuspendedBubbleDemo示例项目,详细展示了在drawRect:中利用Core Graphics绘制圆形泡泡、通过CADisplayLink或NSTimer周期性更新位置实现浮动、使用UIBezierPath的bezierPathContainsPoint:方法检测屏幕边缘碰撞并模拟反弹等关键代码;同时配有演示视频,便于对照运行效果。通过这套示例,开发者可以快速掌握iOS中图形绘制与物理交互的组合应用,并迁移到天气气泡、消息提示等真实场景。 做 iOS 的应该都接到过这样一个需求:产品指着某个 App 上的悬浮球说,“我们也加一个,能拖、能吸附、点开还要有快捷菜单”。第一次听觉得不难,不就是个圆球吗?真上手才发现,里边的坑比想象中多。这篇文章就是我实现 iOS 浮动泡泡功能时留下的完整笔记,从最基础的拖拽、吸附,到点击穿透、旋转屏幕、键盘遮挡,再到单例管理和折叠菜单,全部整理出来。不管你是刚接触 iOS 开发,还是已经写了两三年项目,都能在这里找到能直接照抄的实现思路和源码片段。
1. 动手前先想清楚:悬浮球是“功能”还是“组件”
1.1 需求千奇百怪,但最终都会落在三种形态上
先别急着写代码,把需求吃透很重要。我做过好几个带悬浮球的项目,发现产品口中的“浮动泡泡”最终都能归成三类:
| 形态 | 典型场景 | 技术难点 |
|---|---|---|
| 单个快捷入口 | 客服按钮、购物车入口 | 几乎没有 |
| 可拖拽吸附的按钮 | 悬浮球随手拖动并自动靠边 | 手势判断、边界限制 |
| 可展开的容器 | 点开后弹出多个操作项 | 动画冲突、外部点击收起 |
这三种形态对技术实现的要求完全不一样。第一种最简单,一个 UIButton 加上固定约束就够了,连自定义 View 都不用写。第二种涉及手势识别、边界计算和吸附动画,属于本文要讲的核心内容。第三种除了手势,还要额外处理展开收起时的层级关系、动画冲突和外层点击监听。
所以开工以前,我会先和产品对齐“到底要交互到什么程度”。最怕的是第一版只提“加个可拖的球”,结果代码写完了,他又说“点开还要有菜单、还要能换位置、还要记住用户上次放在哪一边”。这些需求如果前期定了,后面能少改很多代码。
有时候产品让你做悬浮球,其实是体验设计上偷懒。比如我有次做完以后,产品又希望展开之后能展示优惠券,接着又要弹一个 WebView,做成了套娃入口。最后我主导把这块抽象成一个容器组件,业务方往里塞子页面就行。所以我的建议是:哪怕需求文档只写了“一个圆球”,你也要按“一个能被各种业务复用的组件”去设计,而不是写死在首页控制器里。
1.2 iOS 的窗口体系决定了它很难做“系统级悬浮”
这里要先泼一盆冷水:普通 App 无法在系统桌面或者其他应用之上显示悬浮球。iOS 的窗口体系和安全机制决定了第三方应用只能在自己 App 的窗口里操作,没有权限把自己的视图盖到别的应用上。很多产品经理会拿系统自带的辅助触控 AssistiveTouch 举例,但那个是系统级功能,普通开发者是调不到的。
所以我们要把目标明确为“在当前 App 内部的全局悬浮”,也就是在 App 的任意页面上都能呼出、拖拽这个泡泡。这样设计时,技术方案才不会被带偏。
有了这个前提,实现思路就清晰了:用一个全局的视图管理对象,把泡泡挂到当前 App 的 keyWindow 上,或者直接放进主窗口的 rootViewController 里,让它在绝大多数页面之上。至于要不要单独创建一个 UIWindow 去承载它,这里有个大坑,我会在第 4 部分专门讲。
2. 从零实现一个可拖拽的泡泡:手势处理比动画更关键
2.1 先写一个能“动”的视图
定义一个最基础的FloatingBubbleView,继承自 UIView。尺寸我习惯用 56x56,这个大小不大不小,手指操作很舒服,也符合苹果 44pt 最小点击区域的规范。
import UIKit final class FloatingBubbleView: UIView { var onTap: (() -> Void)? private var initialCenter: CGPoint = .zero private var movedDistance: CGFloat = 0 override init(frame: CGRect) { super.init(frame: frame) setupUI() setupGesture() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupUI() { backgroundColor = UIColor.systemIndigo layer.cornerRadius = bounds.width / 2 layer.shadowColor = UIColor.black.cgColor layer.shadowOpacity = 0.25 layer.shadowOffset = CGSize(width: 0, height: 3) layer.shadowRadius = 6 } // ... }这里有个细节:layer.masksToBounds和layer.shadowXXX不能同时开启,否则阴影会被圆角裁剪掉。如果你既要圆角又要阴影,要么像我这样只设cornerRadius不设masksToBounds,加阴影的时候保持背景色是纯色,就不会露馅;要么套两层 View,外层加阴影,内层做圆角裁剪。实际项目里用后一种方案更省心,因为一旦背景要换成渐变色或毛玻璃,只设 cornerRadius 就不够用了。
2.2 拖拽逻辑为什么用 UIPanGestureRecognizer 而不是 touchesMoved
有些小白会从touchesBegan/touchesMoved开始写拖拽,等写到后面就会发现要考虑touch的cancelled状态、判断是单击还是拖拽、以及和UITapGestureRecognizer的冲突,非常繁琐。直接用UIPanGestureRecognizer,它内部已经处理好了状态机,一个手势就覆盖了“开始、移动、结束、取消”四个阶段。
private func setupGesture() { let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) addGestureRecognizer(pan) let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) tap.numberOfTapsRequired = 1 addGestureRecognizer(tap) }拖拽处理的核心代码如下:
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) { guard let superView = superview else { return } switch gesture.state { case .began: initialCenter = center movedDistance = 0 case .changed: let translation = gesture.translation(in: superView) movedDistance = max(movedDistance, abs(translation.x) + abs(translation.y)) var centerX = initialCenter.x + translation.x var centerY = initialCenter.y + translation.y let halfW = bounds.width / 2 let halfH = bounds.height / 2 let limitRect = superView.bounds.inset( by: UIEdgeInsets(top: 44, left: 8, bottom: 34, right: 8) ) centerX = min(max(centerX, limitRect.minX + halfW), limitRect.maxX - halfW) centerY = min(max(centerY, limitRect.minY + halfH), limitRect.maxY - halfH) center = CGPoint(x: centerX, y: centerY) case .ended, .cancelled: if movedDistance < 10 { // 判断为点击,走点击逻辑 onTap?() } else { snapToEdge() } default: break } }movedDistance这个变量比较关键,我用它区分“点击”和“拖拽”。手势是连续的,如果只依赖UITapGestureRecognizer,在快速拖动时会经常出现“明明拖了很远,松手却触发了点击”的误判。所以我手动累计拖拽位移,超过 10pt 就认为是拖拽,否则算点击。这个阈值不能太小,太小说不准,也不能太大,否则用户短距离拖动会没反馈,10pt 是我试下来比较舒服的值。
2.3 边缘吸附动画:用弹簧而不是硬切
拖拽结束后,泡泡要自动吸附到屏幕左边缘或右边缘,这是浮动球最有手感的地方。如果直接center = CGPoint(x: targetX, y: center.y)会非常生硬,我用UIView.animate搭配弹簧参数来做。
private func snapToEdge() { guard let superView = superview else { return } let targetX: CGFloat if center.x <= superView.bounds.midX { targetX = superView.bounds.minX + 8 + bounds.width / 2 } else { targetX = superView.bounds.maxX - 8 - bounds.width / 2 } UIView.animate( withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.4, options: [.curveEaseOut, .beginFromCurrentState], animations: { self.center = CGPoint(x: targetX, y: self.center.y) } ) }参数里有个细节:options要加上.beginFromCurrentState,否则用户手指还在快速滑动时突然松手,动画会从旧位置跳过去,体感很突兀。弹簧阻尼damping我放在 0.8 是为了“有一点点弹,但不至于回弹好几下”,0.6 以下拖拽感太俏皮,商用产品里反而显得廉价。
这里还要提醒一句:center的边界限制不能写死,不能假设superview.bounds永远不变。项目里如果做了横竖屏适配,bounds会在旋转时变化,后面我会专门讲这块。
3. 浮动泡泡的“事件边界”:点击、拖拽与穿透问题
3.1 点击和拖拽的判定:别让用户想点菜单却变成拖走
上面代码里我用movedDistance < 10判定点击,实际上还可以做得更稳一点。有些场景下用户想快速点开菜单,手指会有一点抖动,位移可能超过 10pt,这时候可以用时间辅助判断。记录begin到end的时间差,如果时间小于 0.2 秒,即使位移到了 15pt 也可以认为它是点击。当然这个阈值是经验值,可以根据产品调性去调。
另外,UITapGestureRecognizer和UIPanGestureRecognizer同时加到一个 View 上时,系统默认会等pan手势失败以后才让tap触发,这会导致点击响应有延迟。如果你希望点击后立刻有反馈,可以把tap的require(toFail: pan)改成默认,反正我通过movedDistance做了手动兜底,实际体验很跟手。
3.2 hitTest 与事件穿透:你挡住的不只是画面
很多第一次写浮动球的人都会遇上一个问题:球明明只有一半大,为什么旁边的按钮点不动了?这是因为 UIView 默认的hitTest是按它的 frame 算的,只要 touch 落在 frame 内,就认为这个 View 命中了,哪怕那个区域是圆角外的透明区。
解决办法很直接,重写hitTest或point(inside:with:),手动判断触摸点是否落在圆形范围内:
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { let center = CGPoint(x: bounds.width / 2, y: bounds.height / 2) let distance = hypot(point.x - center.x, point.y - center.y) return distance <= bounds.width / 2 }这样泡泡的四个圆角外区域就会“漏”过去,把点击事件让给下面的页面。
这里要重点说一个特殊场景:如果你为了全局显示,给泡泡新建了一个全屏 UIWindow,那么即使point(inside:)返回 false,事件也不会像想象中那样自动落到下层窗口。iOS 对多窗口事件分发的规律是“上层窗口拦截一切触摸,除非上层窗口手动把事件继续往底层发”。我踩过这个坑以后,给出的建议是:
- 新手或者项目时间紧张时,不要自己创建 UIWindow,直接把泡泡加到当前 keyWindow 的 rootViewController.view 上。
- 如果一定要用独立 UIWindow 承载,尽量让这个 UIWindow 的 frame 等于泡泡本身的大小,随泡泡一起移动,而不是做成全屏透明层。
全屏透明层想要完美穿透,需要重写 UIWindow 的hitTest并返回nil,但不同 iOS 版本上偶尔会有点击延迟和动画卡顿的问题,没必要给自己找麻烦。
3.3 屏幕旋转、键盘弹出和分屏:位置修正策略
屏幕旋转时,如果泡泡只是把 center 限制在旧 bounds 内,横竖屏切换后它很可能直接飞出屏幕。处理办法是监听旋转回调,重新判断边界:
override func layoutSubviews() { super.layoutSubviews() // 父视图 bounds 变化时,确保泡泡仍留在安全边界内 guard let superView = superview else { return } let halfW = bounds.width / 2 let halfH = bounds.height / 2 center.x = min(max(center.x, superView.bounds.minX + halfW), superView.bounds.maxX - halfW) center.y = min(max(center.y, superView.bounds.minY + halfH), superView.bounds.maxY - halfH) }键盘弹出也是个常见的遮挡问题。如果泡泡吸附在底部,键盘一弹出来就正好盖住。我的做法是监听UIResponder.keyboardWillChangeFrameNotification,拿到键盘的 frame 之后,如果键盘会和泡泡 overlap,就把泡泡上移到键盘顶端上方,等键盘收起再回到原来的 y 坐标。注意这里不能用willHideNotification,因为 iPad 外接键盘、输入法切换这些场景,键盘高度的变化比单纯“显示/隐藏”更复杂。
分屏模式下 safeArea 会跟着窗口宽度变化,尤其 iPad 上用户把 App 从三分之二宽度拖成二分之一宽度时,如果代码里写死UIScreen.main.bounds,一样会跑偏。从 iOS 13 开始,Apple 建议用window?.screen.bounds或window?.frame而不是UIScreen.main.bounds,就是这个原因。
4. 接线上之后才会遇到的问题和排查思路
4.1 泡泡“丢”了:生命周期和单例要一起管
浮动泡泡最大的坑不是写不出来,而是写着写着“丢了”。我遇到过这些情况:
- 从 A 页面 push 到 B 页面,泡泡还在;从 B pop 回来,泡泡没了。
- 页面切换时控制台报错:
view is already in use by another view hierarchy。 - 泡泡偶尔会出现两个,把屏幕左边一个右边一个。
根因基本都是同一个:没有用单例统一持有泡泡视图。你把它 add 到某个页面控制器的 view 上,页面一释放,泡泡也被释放了。你换一个页面又 add 一次,但旧泡泡还在旧视图树里,于是重复创建。
我封装了一个极简的BubbleManager:
final class BubbleManager { static let shared = BubbleManager() private var bubbleView: FloatingBubbleView? func show(in hostView: UIView) { if let bubble = bubbleView { if bubble.superview != hostView { bubble.removeFromSuperview() hostView.addSubview(bubble) } return } let bubble = FloatingBubbleView(frame: CGRect(x: 0, y: 220, width: 56, height: 56)) bubbleView = bubble hostView.addSubview(bubble) } func removeBubble() { bubbleView?.removeFromSuperview() bubbleView = nil } }使用的时候,在AppDelegate或者根控制器里调用一次BubbleManager.shared.show(in: keyWindow.rootViewController.view),之后所有页面都复用同一个实例。页面切换时它不会被轻易释放,因为单例强持有它,只有显式调用removeBubble()才会真正干掉。
中间有个细节:从一个页面换到另一个页面时,hostView变了,要先removeFromSuperview再addSubview。如果直接在新页面上 add 同一个 view,系统会报 “already in use” 的约束冲突,视图的位置、frame 也会变得不可控。
4.2 动画期间的坐标错乱:约束和 frame 别混用
我见过有人用 Auto Layout 给泡泡做约束,拖拽时改center,结果约束和center互相打架,泡泡拖一下就弹回去。我的建议是:浮动球这种高频位置更新的视图,不要用约束控制位置,直接用 frame/ center 会更稳。如果要加菜单展开这种“结构变化”的动画,也应该用一个容器 View 包住,容器的位置用 center 控制,内部的菜单项用约束或 frame 动态算,两边不冲突。
还有一个容易踩的点:拖拽过程中如果同时有按钮的isHighlighted状态、阴影、模糊背景,可能会导致离屏渲染,表现在低端机型上就是“跟手但掉帧”。影子用shadowPath固定形状能有效减少性能损耗;如果不需要复杂阴影,我更喜欢把内层视图做成masksToBounds = true,外层用一个 2pt 透明边距的容器做阴影,虽然多了一层 View,但实测滚动页面时稳定不掉帧。
4.3 性能和耗电:一个 56pt 的小圆球也能拖垮页面吗
一个小圆球平时放在那里,CPU 占用几乎为零,但如果加了一堆持续动画,例如呼吸光圈、粒子特效、毛玻璃,问题就来了。
UIVisualEffectView毛玻璃在拖拽时尤其费性能,因为每一帧都要重新采样背景。我在项目里会把毛玻璃的effect设为nil,拖拽结束以后再恢复,效果不错。- 不要用
UIView.animate做无限循环动画,推荐用UIViewPropertyAnimator,配合isReversed或fractionComplete,既能省内存,又能随时停掉。 - 如果泡泡上有角标数字或者图片,尽量用
CALayer.setNeedsDisplay去更新,而不是频繁替换UIImageView.image。
5. 从能用变得好用:折叠菜单、状态同步和位置记忆
5.1 折叠菜单的展开收起动画:注意 transform 导致的点击热区偏移
当产品说“点开要有菜单”,你就得把单个泡泡升级成容器。菜单项通常是围绕主球展开的,我会用一个menuContainer包住这几个按钮,展开时从 scale 0 动画到 1:
func toggleMenu() { if isExpanded { UIView.animate(withDuration: 0.2, animations: { self.menuContainer.alpha = 0 self.menuContainer.transform = CGAffineTransform(scaleX: 0.8, y: 0.8) }) { _ in self.menuContainer.isHidden = true } } else { menuContainer.isHidden = false menuContainer.transform = CGAffineTransform(scaleX: 0.6, y: 0.6) UIView.animate(withDuration: 0.25, delay: 0, usingSpringWithDamping: 0.7, initialSpringVelocity: 0.3, options: [.curveEaseOut]) { self.menuContainer.transform = .identity self.menuContainer.alpha = 1 } } isExpanded.toggle() }用transform做 scale 有个坑:transform不会改变 view 的 frame,但是会改变实际渲染位置和点击热区。如果你展开菜单项用的是约束,展开后 menuContainer 的 frame 可能还是 0,点击菜单按钮会失效。解决方法是在动画开始前先强制把 menuContainer 的 frame 撑开,或者用layoutIfNeeded提交一次布局,再执行 transform 动画。我在实际项目里更倾向于直接把菜单项的位置用计算好的 frame 手动排布,虽然多写几行代码,但动画和点击都能控制得很准确。
展开方向也要考虑。主球吸附在左边时,菜单应该向右展开;吸附在右边时,菜单向左展开。展开后的区域不能超过屏幕边界,所以展开前要判断泡泡当前贴在哪一边:
let shouldExpandToLeft = center.x > superview!.bounds.midX如果你忽略了这一步,菜单往屏幕外展开,用户一半内容看不到,就会变成最典型的“看起来能做但不专业”。
5.2 跨页面状态同步:单例 + 通知比代理更好用
如果泡泡只承载一个“去客服”按钮,用onTap闭包就够了。但如果它要在多个页面里显示不同的菜单项、角标数字,就要做状态同步。
我推荐用BubbleManager提供对外接口:
extension BubbleManager { func updateBadge(_ text: String?) { bubbleView?.badgeLabel.text = text bubbleView?.badgeLabel.isHidden = (text == nil) } func setMenuItems(_ items: [BubbleMenuItem]) { bubbleView?.menuItems = items } }页面在viewWillAppear里调用BubbleManager.shared.updateBadge(...)时,不需要页面之间互相知道,因为泡泡的状态完全由业务方主动驱动,这样最符合 Cocoa 的模型-视图-控制器模式。如果用通知总线去监听一堆全局事件,反而会把组件搞得难以调试。
5.3 产品体验层面:位置记忆和可关闭性
最后聊一个经验上的细节。浮动球在 iOS 上默认会挡住内容,用户很容易遇到“想点的按钮被球挡住了”的烦恼。所以一个成熟产品里应该提供:
- 长按泡泡出现“取消悬浮球”入口;
- 设置页里有开关控制是否显示;
- 用户把泡泡拖到哪里,退出 App 以后下次启动还停在原位。
位置记忆用UserDefaults存一个点就够了:
let key = "floating_bubble_center" UserDefaults.standard.set( NSCoder.string(for: bubble.center), forKey: key )启动时读出来,如果位置不在安全区内就做一次边界修正。这个小功能很多产品不一定提,但做了之后用户好感度会明显上升。
做多了之后我的体感是:浮动泡泡这类组件,表面上是“一个 UIView 加一个手势”,实际上牵扯到窗口层级、事件分发、安全区适配、生命周期管理、性能优化五个层面的问题。如果动手前把这几个问题想清楚,写起来会顺手很多。尤其是事件穿透和多页面复用这两块,建议在第一个 Demo 阶段就要验证掉,否则后面越写越乱。
本文还有配套的精品资源,点击获取