ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

iOS悬浮球组件开发实战:拖拽吸附、手势冲突与碰撞检测全解析

iOS悬浮球组件开发实战:拖拽吸附、手势冲突与碰撞检测全解析 简介面向iOS开发者的浮动泡泡功能实现资料包围绕自定义视图、Core Graphics绘制、CADisplayLink定时驱动和UIBezierPath碰撞检测展开适合想掌握气泡动画、视图交互与物理反弹效果的开发者参考。压缩包共24个文件、大小7.34MB其中m/h源文件涵盖视图工具、定时器扩展、AppDelegate和主控制器代码storyboard与plist负责界面布局和工程配置png用作泡泡素材mov则是功能演示录屏此外还包含xcodeproj工程文件、Scheme和断点调试记录便于直接打开工程跟进实现。已有695人学习。资源中的SuspendedBubbleDemo示例工程直接呈现完整流程先绘制圆形泡泡再通过动画更新位置最后用UIBezierPath检测屏幕边缘并模拟反弹源码对绘制和碰撞模块做了拆分配合演示视频可快速理解每个技术点的作用适合边看边练、对照调试验证。1. 功能定型与需求拆解先聊清楚一件事浮动泡泡到底是什么用大白话说就是一个悬浮在 App 界面最上层、可以随意拖动、点击后能展开或触发某个功能的圆形控件。它可能是一个客服入口、一个快捷操作按钮、一个播放器的迷你控制球、也可能是一个实时状态指示器。我这次实现的是通用能力一个可拖拽、可边缘吸附、点击展开/收起内容面板的浮动泡泡组件供主业务方以极低改造成本接入使用。这类功能之所以值得专门写一篇博文不只是因为“把按钮放在屏幕上”这件事看起来简单而是因为它在 iOS 平台上涉及到的细节特别多窗口层级的正确选择、拖拽过程中的手势处理、边缘吸附的动画曲线、碰撞检测的性能开销、横竖屏切换时的坐标恢复、以及和其他手势比如列表滑动、侧滑返回的冲突协调。任何一个点处理不到位用户的体感就会变成“卡”“飘”“跟手度差”。如果你准备在项目中落地这个功能这篇博文会把思路、代码、踩坑都拆开来讲。适合有一定 UIKit 基础、接手了类似悬浮球/悬浮窗需求、或者纯粹想给自己的 App 加一个顺手快捷入口的 iOS 开发者。哪怕你之前没做过类似交互跟着走一遍也能把核心逻辑吃透。先把需求固定下来这个浮动泡泡具备以下几个行为和特性。支持全局任意页面显示App 切后台回来之后不丢状态。可拖拽跟随手指移动松手后自动吸附到离它最近的屏幕边缘。点击泡泡本体展开一个内容面板点击面板外部区域面板收起。泡泡在拖动和吸附时带轻微的弹簧动画交互不生硬。多个泡泡同时存在时互相之间有碰撞避让不会叠成一坨。支持横竖屏切换后位置自适应。对外暴露最小化接入接口业务方传一个内容视图即可完成挂载。这个需求清单是经过取舍的。很多第一次做悬浮球的人会忍不住想加“所有页面可见”“点击穿透”“全局事件拦截”这类能力但实际落地后你会发现绝大多数场景真正需要的只是上面这些。功能收窄之后实现复杂度能降一个量级维护成本也低很多。2. 方案选型与架构设计2.1 挂载层选择UIWindow 还是 keyWindow 上 addSubview实现浮动泡泡第一件事是决定它挂在哪一层。常见做法有两种直接往UIApplication.shared.keyWindow上 addSubview或者单独创建一个UIWindow把泡泡放进这个 window 的 rootViewController 里。我选的是后一种方案。原因有三个。第一单独 window 可以设置windowLevel。iOS 的 window 层级从低到高依次是UIWindowLevelNormal、UIWindowLevelStatusBar、UIWindowLevelAlert。如果把泡泡放进普通 window当弹出系统弹窗或者调起键盘时泡泡会被盖住单独开一个窗口并把 level 设到UIWindowLevelAlert以上才能保证泡泡始终保持在视觉最上层。第二独立 window 不影响现有视图控制器的层级结构。在 keyWindow 上直接 addSubview会引入“这个 subview 到底属于哪个 VC”的归属问题处理内存和旋转时容易出幺蛾子。用独立 window 之后泡泡自己管理自己的生命周期业务方接进来就是一个对象清爽很多。第三后续如果要扩展到“App 内所有页面都能显示”独立 window 天生就能做到不需要在每一个 VC 的 viewWillAppear 里做埋点注入。需要注意一点这个 window 不需要 becomeKey否则会影响系统的输入焦点逻辑。初始化的时候只要window.isHidden false就够了让泡泡窗口做一个纯粹的展示层。2.2 控件形态从 UIControl 子类 自绘开始泡泡本身我用了一个自定义的UIControl子类。为什么不直接用 UIButton因为后续要加角标、加渐变背景、加不同状态下的形态切换UIButton 的配置自由度不够。用 UIControl 子类再加上draw(_:)自绘形状、颜色、阴影、内嵌小图标都能完全自己控制。绘制上其实没多少花活圆形路径 渐变图层 阴影 中心 ICON。性能方面这种静态绘制只发生一次不会在每一帧都重新渲染比在 layoutSubviews 里反复改 frame 高效得多。2.3 拖拽与吸附UIPanGestureRecognizer 自带的状态机拖拽我用UIPanGestureRecognizer没有自己去实现 touchesMoved 那一套。手势识别器的状态机possible/ began / changed / ended能省掉很多越界判断的麻烦。核心逻辑集中在 changed 和 ended 两个状态里处理。吸附的核心算法不复杂根据手势结束时的中心坐标分别计算到屏幕左右两边缘的距离取最小值作为吸附目标。然后配合UIView.animate的弹簧参数实现回弹表达式如下UIView.animate( withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.6, initialSpringVelocity: 0.8, options: [.curveEaseOut, .beginFromCurrentState], animations: { [weak self] in self?.center targetPoint }, completion: nil )这段代码里的usingSpringWithDamping和initialSpringVelocity是手感的关键。阻尼系数 0.6 左右会有一点轻微弹跳但不至于像果冻一样晃个不停初始速度 0.8 则让吸附动作看起来更“跟手”。这两个值需要真机调试模拟器上的阻尼表现和真实设备有细微差别建议以真机为准。3. 核心代码实现与关键细节3.1 浮动窗口的构建先给出悬浮窗口的基础实现。这是一个轻量级 window 子类只负责承载泡泡不参与 keyWindow 竞争。final class FloatWindow: UIWindow { init() { super.init(frame: UIScreen.main.bounds) self.windowLevel .alert 1 self.backgroundColor .clear self.isHidden false self.isUserInteractionEnabled true } required init?(coder: NSCoder) { fatalError(init(coder:) has not been implemented) } // 避免窗口影响状态栏样式和键盘逻辑 override var canBecomeKey: Bool { false } override func hitTest(_ point: CGPoint, with event: UIEvent?) - UIView? { // 只有点击到泡泡本身时才响应其余区域全部透传 let view super.hitTest(point, with: event) return view self ? nil : view } }这段代码里最值得留意的是hitTest的 override。窗口本身是全屏的如果不做拦截它会拦截所有触摸事件导致 App 下面的页面点不了。我们只让有内容的子视图消费点击事件窗口自身永远返回 nil这样泡泡显示在最上层的同时不给底层界面添乱。创建窗口实例之后把泡泡加进去。注意不要设置窗口的 rootViewController直接 addSubview 即可进一步降低耦合。let floatWindow FloatWindow() let bubble FloatingBubble(frame: CGRect(x: 0, y: 0, width: 56, height: 56)) floatWindow.addSubview(bubble)3.2 泡泡自身的手势逻辑和吸附计算泡泡本体的核心实现如下。我尽量把代码控制在一个文件内方便你复制后直接改。final class FloatingBubble: UIControl { private var initialCenter: CGPoint .zero private let edgeInsets: CGFloat 12 private var isExpanded false private var contentPanel: UIView? // 背景视觉层 private let gradientLayer CAGradientLayer() private let iconLabel UILabel() override init(frame: CGRect) { super.init(frame: frame) setupUI() let pan UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) pan.maximumNumberOfTouches 1 addGestureRecognizer(pan) } required init?(coder: NSCoder) { fatalError(init(coder:) has not been implemented) } private func setupUI() { clipsToBounds false layer.shadowColor UIColor.black.cgColor layer.shadowOpacity 0.25 layer.shadowRadius 8 layer.shadowOffset CGSize(width: 0, height: 4) gradientLayer.colors [ UIColor.systemBlue.cgColor, UIColor.systemTeal.cgColor ] gradientLayer.cornerRadius bounds.width / 2 layer.insertSublayer(gradientLayer, at: 0) iconLabel.textAlignment .center iconLabel.text ⌕ iconLabel.font .systemFont(ofSize: 20, weight: .medium) iconLabel.textColor .white addSubview(iconLabel) } override func layoutSubviews() { super.layoutSubviews() gradientLayer.frame bounds gradientLayer.cornerRadius bounds.width / 2 iconLabel.frame bounds } objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { guard let superview superview else { return } switch recognizer.state { case .began: // 收起面板保证拖动时不会带着一大块内容移动 collapsePanelIfNeeded() initialCenter center // 放大一点点提示用户当前处于拖动状态 scaleAnimation(scale: 1.08) case .changed: let translation recognizer.translation(in: superview) center CGPoint(x: initialCenter.x translation.x, y: initialCenter.y translation.y) case .ended, .cancelled, .failed: scaleAnimation(scale: 1.0) snapToEdge(in: superview) default: break } } private func snapToEdge(in superview: UIView) { let safeAreaFrame superview.bounds.inset(by: superview.safeAreaInsets) let leftDistance abs(center.x - safeAreaFrame.minX) let rightDistance abs(safeAreaFrame.maxX - center.x) let minX safeAreaFrame.minX bounds.width / 2 edgeInsets let maxX safeAreaFrame.maxX - bounds.width / 2 - edgeInsets // 同时保持 y 坐标不超出安全区域 let minY safeAreaFrame.minY bounds.height / 2 edgeInsets let maxY safeAreaFrame.maxY - bounds.height / 2 - edgeInsets let targetX leftDistance rightDistance ? minX : maxX let targetY min(max(center.y, minY), maxY) let targetPoint CGPoint(x: targetX, y: targetY) UIView.animate( withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.6, initialSpringVelocity: 0.8, options: [.curveEaseOut, .beginFromCurrentState], animations: { [weak self] in self?.center targetPoint }, completion: nil ) } private func scaleAnimation(scale: CGFloat) { UIView.animate(withDuration: 0.2, delay: 0, usingSpringWithDamping: 0.5, initialSpringVelocity: 1.0, options: [.beginFromCurrentState], animations: { [weak self] in self?.transform CGAffineTransform(scaleX: scale, y: scale) }) } }吸附判断这里有一个关键点目标点同时要对 y 坐标做安全区域钳制。很多实现只处理左右吸附忽略了状态栏、底部 Home Indicator 区域对 y 轴的限制导致最后泡泡跑到一块很难点到的位置上去。上面的代码直接用safeAreaInsets做了范围约束实测在刘海屏、非刘海屏、横屏下都表现正常。3.3 面板的展开与收起的两种交互模式展开面板我给了两套模式通过一个枚举切换业务方按需选择。模式一以泡泡为中心弹出一个气泡式面板类似聊天气泡里的菜单。模式二面板从泡泡所在边缘平滑滑出适合放稍微大一点的展示内容。这里我以比较常用的气泡式面板为例。面板本身是一个自定义 UIView在展开时从泡泡的中心点做一个缩放 透明度组合动画。收起时反向执行。func togglePanel(expanded: Bool, contentView: UIView?) { guard let contentView contentView else { return } guard let superview superview else { return } if expanded { contentView.center center contentView.alpha 0 contentView.transform CGAffineTransform(scaleX: 0.6, y: 0.6) superview.addSubview(contentView) self.contentPanel contentView UIView.animate( withDuration: 0.25, delay: 0, usingSpringWithDamping: 0.7, initialSpringVelocity: 0.6, options: [.curveEaseOut], animations: { contentView.alpha 1 contentView.transform .identity } ) } else { UIView.animate( withDuration: 0.2, animations: { self.contentPanel?.alpha 0 self.contentPanel?.transform CGAffineTransform(scaleX: 0.6, y: 0.6) }, completion: { _ in self.contentPanel?.removeFromSuperview() self.contentPanel nil } ) } }这里有个内存管理的小细节contentPanel 在展开时要强引用住并且要在收起动画完成后及时 removeFromSuperview 和置空避免同一块内容视图被重复 add 到 window 上导致奇怪的显示问题。另外面板的锚点位置最好根据泡泡当前在屏幕的哪一侧做镜像调整。泡泡靠左时面板往右展开靠右时面板往左展开否则面板有一部分会跑到屏幕外面去。锚点判断放在展开前的那一步对比 center.x 和 superview.center.x 的大小即可。4. 多泡泡碰撞检测与交互增强先声明如果只做一个悬浮球碰撞检测可以跳过。这个章节给有“两个及两个以上悬浮球”需求的场景。比如主按钮加上一个音频迷你控制球、或者一个消息提醒球共存的情况。碰撞检测的实现思路不复杂用一个CADisplayLink定时器每帧读取所有泡泡的 frame计算圆心距。如果两个圆的圆心距小于两个半径之和就表示发生了碰撞。看起来简单但全量用 CADisplayLink 每帧驱动所有逻辑CPU 开销会非常难看。我的优化策略是分级检查采样频率降到 10Hz每 0.1 秒检查一次并且只检查两两组合数量少的时候开销几乎可以忽略。如果有七八个以上的泡泡建议用空间哈希网格来加速但一般业务场景根本用不到。final class BubbleCollisionManager { private var displayLink: CADisplayLink? private(set) weak var bubbleContainer: UIView? private var bubbles: [FloatingBubble] [] init(container: UIView) { self.bubbleContainer container } func start() { displayLink CADisplayLink(target: self, selector: #selector(tick)) displayLink?.preferredFrameRateRange CAFrameRateRange(minimum: 5, maximum: 15) displayLink?.add(to: .main, forMode: .common) } func stop() { displayLink?.invalidate() displayLink nil } func register(_ bubble: FloatingBubble) { if !bubbles.contains(bubble) { bubbles.append(bubble) } } objc private func tick() { guard let container bubbleContainer else { return } let count bubbles.count guard count 1 else { return } for i in 0..count { for j in (i1)..count { let a bubbles[i] let b bubbles[j] let distance hypot(a.center.x - b.center.x, a.center.y - b.center.y) let minDistance (a.bounds.width b.bounds.width) / 2 if distance minDistance { resolveCollision(between: a, and: b, minDistance: minDistance) } } } } private func resolveCollision(between a: FloatingBubble, and b: FloatingBubble, minDistance: CGFloat) { let deltaX b.center.x - a.center.x let deltaY b.center.y - a.center.y let distance max(hypot(deltaX, deltaY), 0.1) let overlap minDistance - distance let direction CGPoint(x: deltaX / distance, y: deltaY / distance) UIView.animate(withDuration: 0.2) { b.center CGPoint(x: b.center.x direction.x * overlap, y: b.center.y direction.y * overlap) } } }碰撞处理的主题思想是“只推走一个另一个不动”。这样主观感受上更自然因为人类眼睛会优先追踪先放置的那个对象。如果两个都同时弹开反而觉得突兀。CADisplayLink有个常见的坑target 对 self 是强引用使用完成后一定要调用 stop否则定时器不会被释放。建议在管理类的 deinit 里调用 stop同时在 App 进入后台的时候也把它暂停掉回到前台再恢复。iOS 的墓碑机制下后台一直跑着 CADisplayLink 其实不会一直执行但保险起见还是监听一下生命周期。5. 旋转适配、手势冲突和性能优化5.1 横竖屏切换后位置恢复独立 window 和普通 view 一样需要处理旋转。在没有开启全局横屏的 App 里这个问题比较简单如果业务方支持横屏则需要监听屏幕方向变化在方向切换完成后重置泡泡的位置。具体做法是监听UIDevice.orientationDidChangeNotification或者使用viewWillTransition(to:with:)。我建议用后者因为它是和当前的窗口场景绑定在一起的更可靠。收到回调后首先把泡泡从旧位置挪到一个可见的默认位置保证它在旋转动画过程中不会停留在屏幕外。再在动画结束后执行一次吸附逻辑。override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) coordinator.animate(alongsideTransition: nil) { [weak self] _ in guard let self self else { return } self.bubble.center CGPoint(x: size.width - 80, y: size.height - 120) self.snapToEdge(in: self.superview ?? UIView()) } }这个方案简单直接实测没有出现过泡泡在旋转后丢失或者跑到屏幕外边的情况。5.2 手势冲突的规避浮动泡泡在手势层面最大的冲突源是 UIScrollView 的滑动。因为泡泡上有 UIPanGestureRecognizer同一个手指在屏幕上移动时系统会同时触发滚动和泡泡拖拽最终表现就是一边拉滚动一边拖泡泡体感很差。解决思路是把手势识别器的cancelsTouchesInView设为 true并在代理方法里根据手势的方向和位置做判断。extension FloatingBubble: UIGestureRecognizerDelegate { func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) - Bool { // 如果是和 ScrollView 的 pan 冲突不进行同时识别 if otherGestureRecognizer is UIPanGestureRecognizer { return false } return false } }另外一个更实用的策略给泡泡增加一块可拖拽的“热区”。如果用户是从泡泡内部开始拖的才触发移动如果手指一开始落在泡泡外面的列表区域则完全交给列表自己处理。为了实现这个我一般会把isExclusiveTouch设为 true再在 hitTest 时做一个触摸起始点判断。这样能大幅降低误触和手势抢占的概率。5.3 隐式性能开销图层和阴影悬浮泡泡这种常驻控件在性能上最容易被忽略的是阴影和圆角。如果直接给整个视图加cornerRadius并配合clipsToBounds会触发离屏渲染当泡泡在拖动过程中每一帧都触发离屏渲染时掉帧就非常明显了。我的做法是阴影放在最外层的 shadowLayer 上渐变内容放在内部的 gradientLayer 上两个 layer 互相独立圆角直接设置在 gradientLayer 的cornerRadius上并通过设置shouldRasterize true让贝塞尔路径缓存起来减少重复渲染。layer.shouldRasterize true layer.rasterizationScale UIScreen.main.scalerasterizationScale必须显式设置否则在部分设备上会按 1x 渲染导致边缘发虚。这个细节在模拟器上不明显真机上对比一下就非常清楚了。6. 踩坑记录与排查思路写到这里分享几个我在真机调试过程中实际遇到的问题每个都是血泪教训。6.1 窗口不响应点击第一次接入时泡泡确实显示出来了但点击没有任何反应。排查方式用 Debug View Hierarchy 看一下发现 floating window 虽然在最上层但它的isUserInteractionEnabled默认被设成了 false。原因是某些系统 API 在创建 window 时默认关掉了交互。解决方式很简单初始化时手动置为true并且在hitTest里做正确透传。6.2 按钮区域点击无响应但拖动正常这个问题出在 layer 的覆盖范围上。因为我在泡泡上叠加了GradientLayer而 layer 默认是允许交互的它会拦截 touches 事件。解决方式把 gradientLayer 的isUserInteractionEnabled设为 false或者让按钮继承自 UIControl 并把 touches 事件回调在sendAction里处理。这里有一个容易踩的细节UIControl的默认点击区域其实只限于 bounds如果你的iconLabel覆盖了整个 bounds 并且加了isUserInteractionEnabled true也会拦截点击。正确做法是 iconLabel 设置成isUserInteractionEnabled false。6.3 自定义内容面板被系统弹窗遮挡面板从泡泡窗口弹出后如果 App 里同时有系统级弹窗比如 UIAlertController面板会被遮住。这不是布局问题而是 windowLevel 的优先级问题。把泡泡窗口的 level 调高到.alert 1可以解决但要注意如果设置得太高系统键盘弹出时也会被泡泡盖住影响输入体验。折中方案是当有输入框成为第一响应者时自动调低 window level输入结束后再恢复。这个逻辑用 NotificationCenter 监听UIKeyboardWillShow/UIKeyboardWillHide就能做。6.4 拖动过程中偶发抖动原因基本可以锁定在吸附动画的状态冲突在手势结束后的动画过程中用户再次触摸了泡泡导致动画和手势同时修改 center。解决方式是在手势.began时先移除当前所有动画再把 center 设置成 presentationLayer 的当前值。代码实现如下case .began: layer.removeAllAnimations() if let presentation layer.presentation() { center presentation.position } initialCenter center用presentation()获取当前帧的视觉位置再开始拖动这样无论之前的吸附动画是否执行完用户重新拖动时都不会跳动。6.5 真机上圆角边缘发虚模拟器上一切正常一到真机边角就出现锯齿。这个问题出现的原因是没有设置rasterizationScale。模拟器默认跑在高分辨率模式下而真机可能需要显式指定 scale。踩了一次之后我把所有含圆角和阴影的图层都统一加上了layer.rasterizationScale UIScreen.main.scale问题彻底消失。7. 接入方式和对外接口设计最后说一下业务方怎么接入。设计接口的时候我遵循“能一行接入就不要三行”的原则把集成工作收敛成一个单例入口。public final class BubbleManager { public static let shared BubbleManager() private let floatWindow FloatWindow() private var bubble: FloatingBubble? private var collisionManager: BubbleCollisionManager? private init() {} public func showBubble(icon: String? nil, tintColor: UIColor .systemBlue, contentView: UIView? nil) { if bubble nil { let frame CGRect(x: 120, y: 200, width: 56, height: 56) let newBubble FloatingBubble(frame: frame) floatWindow.addSubview(newBubble) bubble newBubble } bubble?.configure(icon: icon, tintColor: tintColor, panelContent: contentView) floatWindow.isHidden false } public func hideBubble() { bubble?.removeFromSuperview() bubble nil floatWindow.isHidden true } public func updatePanelContent(_ view: UIView) { bubble?.updatePanelContent(view) } }这套接口在实践中足够覆盖绝大多数需求。contentView 可以是任意UIView所以业务方不需要关心泡泡内部的布局逻辑只负责把自己要做的东西塞进来就行。如果需要监听泡泡点击或面板事件加一个 block 回调或者 delegate 都行就不展开写了。整个浮动泡泡功能从需求到落地比较大的成本其实不在“让一个圆形视图跑起来”而在交互细节的处理状态管理、动画打断、坐标恢复、多手势协调、性能开销控制。把这些点都过一遍之后做成一个稳定的、可复用的组件就没什么问题了。我自己的经验是在真机上反复测拖动和吸附手感一定要用 iPhone 12 到最新机型都跑一遍尤其是不同屏幕尺寸下 safeAreaInsets 的表现差异比想象中大不少。本文还有配套的精品资源点击获取
返回列表