十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

iOS浮动泡泡开发全解析:基于UIWindow的全局悬浮实现与踩坑指南

iOS浮动泡泡开发全解析:基于UIWindow的全局悬浮实现与踩坑指南 简介面向iOS开发者的浮动泡泡功能实现资料完整涵盖自定义视图绘制、动画驱动与碰撞检测三大技术模块。示例工程采用Objective-C编写通过Core Graphics绘制立体感泡泡基于CADisplayLink驱动位置更新以呈现上升或下降效果并利用UIBezierPath检测屏幕边缘及泡泡间的碰撞配合transform调整实现真实反弹适合有一定OC基础、希望提升动画与交互能力的开发者学习。压缩包共24个文件核心为.m/.h源码、storyboard界面布局、plist配置及png切图另有mov录屏可直观预览运行效果整体大小7.34MB。已有695人学习/下载。工程内附完整Xcode项目目录模块划分清晰可直接运行调试并逐步拆解各部分实现是练习iOS绘图与物理碰撞的实用案例。 直接开工之前先说一句浮动泡泡这个功能在iOS开发里属于那种“看着不起眼、写起来全是坑”的活儿。它不像复杂的业务列表或者炫酷的动画那样能直接量化产出但凡是做过IM、直播助手、悬浮球类工具、或者客服咨询入口的朋友基本都绕不开它。我自己前后在三个项目里反复写过这套逻辑从最早的纯UIView方案到后来基于UIWindow的完整封装踩了不少坑今天把能沉淀下来的经验一次性写清楚。如果你正打算做一个类似“悬浮客服入口”“播放悬浮球”“全局置顶工具按钮”之类的功能这篇文章可以直接帮你在实现思路上少走弯路。本文默认你熟悉iOS基础开发、了解UIWindow和UIViewController的生命周期但即便你偏前端方向只要懂一点iOS原生也完全能跟下来。1. 整体设计与思路拆解1.1 浮动泡泡的本质是什么浮动泡泡从产品形态上看就是一个永远“飘”在所有界面之上的可拖拽圆形控件。它默认贴边停靠用户点一下能展开菜单或跳转页面长按可以进入编辑态拖拽时跟手松手后自动吸附到屏幕左或右边缘。很多时候还会带一个可选的“扫动消失”手势。这里最关键的一点是它不是一个普通的视图而是一个跨页面的全局悬浮层。如果你把它加在某个ViewController的view上用户一跳转它就没了那这个功能就废了。所以正确的宿主必须是UIWindow或者至少是keyWindow级别之上的独立层级。我最终采用的是“独立UIWindow 自定义泡泡视图”的方案。这样做有几个实际好处第一不受当前页面控制器生命周期影响哪怕你在UITabBarController、UINavigationController的push/pop过程中泡泡依然稳定存在第二事件拦截范围可控只要把window的rootViewController设成一个空白控制器再把泡泡作为它的view的子视图就能避免误触到下层页面第三动画和布局的代码可以完全隔离不影响业务页面后续维护成本也低。1.2 为什么不直接用系统控件或者第三方库有人可能会问GitHub上不是有现成的悬浮球库吗为什么还要自己写我在实际项目里确实用过几个比较知名的库但最终都替换成了自研原因很直接一是第三方库的交互细节很难100%匹配产品需求比如有的库拖拽阻尼手感不对有的库长按和拖拽手势冲突二是这类库普遍没有处理键盘弹出、横竖屏切换、分屏拖拽等边界场景三是当泡泡上需要承载业务菜单、红点、头像、进度条等复杂内容时改别人的代码比重新写一套还累。自己实现最大的好处是可以完全掌控手势优先级和动画时长。比如泡泡从右侧吸附到左侧边缘时我希望有一个“弹性回弹”的效果而不是简单的线性移动这种细节用第三方库调起来很麻烦。下面这张表是我当初做技术选型时的对比可以给你参考方案优点缺点系统控件手动管理层级可控性最强需要处理生命周期和事件穿透第三方悬浮球库上手快定制成本高边界场景欠缺自研独立Window方案全局稳定、完全可控初期开发量稍大我选第三套本质上是在“开发效率”和“长期维护”之间做了取舍。如果你只是临时做个Demo用第三方库完全没问题但如果是正式业务功能我建议你至少把“独立Window 手势处理”这套核心逻辑自己实现一遍百利无害。2. 核心细节解析与实操要点2.1 浮动泡泡的技术实现路径整体的技术路径其实只有三步第一步创建一个独立的UIWindow并设置其windowLevel确保它盖在普通业务window之上第二步在这个window上挂一个可拖拽的圆形视图并处理好触摸手势第三步实现拖拽结束后的边缘吸附动画以及点击回调。听起来很简单但每一步都有很多细节。比如windowLevel的设置你不能直接设成UIWindowLevelStatusBar那会盖住状态栏也不能设成UIWindowLevelAlert那会盖住系统弹窗。实际项目中一般建议设成UIWindowLevelNormal 1这个值刚好高于普通业务window又不会干扰系统级别的弹窗展示。创建好window之后需要把它设为全局强引用否则ARC会在window释放时自动把它从屏幕上移除。很多新手第一次写完发现泡泡“闪一下就不见了”八成就是这个原因。我一般会在AppDelegate或者一个单例管理器里持有这个window属性。2.2 手势系统与拖拽自由度的平衡手势是浮动泡泡的灵魂。拖拽手势、点击手势、长按手势这三个必须共存而且不能互相打架。我踩过最大的坑是直接使用UIPanGestureRecognizer时手指轻微移动就会被判定成拖拽导致用户想点击时泡泡却跟着跑了体验非常糟糕。解决方案是设置一个“移动阈值”。具体做法是在pan手势的stateMachine里记录手势开始的起始点当移动距离大于某个值比如10pt时才进入拖拽模式否则在手势结束时判定为点击。这个思路也适用于长按长按手势和拖拽手势共存时用require(toFail:)方法让长按失败后才允许拖拽这样可以实现“按住不动进入编辑态移动则拖拽”的效果。另外还要注意手势添加的位置。如果直接加在泡泡视图上点击事件会被手势拦截。我最终的做法是拖拽手势加在泡泡父视图也就是自定义Window的根视图上但通过hitTest判断触摸点是否落在泡泡范围内再决定是否响应。这样做的好处是手势区域可扩展即使泡泡尺寸较小用户也能在周边小范围内触发拖拽体验更友好。2.3 事件穿透与点击区域优化浮动泡泡本质上是一个“侵入式”控件它悬浮在业务页面上如果处理不当会挡住用户的正常操作。比如泡泡正好悬浮在一个按钮上方用户点击时应该点到按钮还是点到泡泡这取决于产品的定义。在微信的悬浮窗、iPhone的辅助触控小白点里点击泡泡就是点击泡泡它不会把事件穿透给下层页面。但在某些场景下比如直播间的挂件泡泡只占屏幕边缘一小块此时你希望用户在泡泡之外的区域操作都能正常穿透。前者需要自定义Window后者只需要在根视图的point(inside:with:)方法里做判断返回false即可让未命中泡泡的点击穿透到下层。我建议在没有特殊需求的情况下把泡泡设计成“占位小、热区大”。具体实现是泡泡的可视尺寸约56x56pt但触摸热区扩大到约64x64pt。这样既保证了点击精度又不会因为视觉尺寸过大遮挡内容。热区扩大的代码很简单在泡泡的hitTest里把触摸范围向外扩展一圈就行。2.4 生命周期管理与内存释放浮动泡泡是全局单例级别的存在生命周期几乎和App一样长。如果直接用单例持有windowwindow持有rootViewControllerrootViewController持有泡泡视图这整条链在App退出前都不会释放所以绝对不能把业务数据模型强引用在泡泡上否则会造成隐性内存泄漏。我遇到过的一个典型场景是泡泡上承载了一个“用户头像”的UIImageView我直接把网络图片管理器返回的image赋值给了它。由于图片数据比较大而泡泡又是常驻的这导致该图片在整个App生命周期内都无法释放内存上涨明显。这之后我改成了异步加载、用弱引用持有数据源并且增加了泡泡在闲置状态下自动移除图片内容的策略。另外在收到UIApplicationDidReceiveMemoryWarningNotification时也应该主动清理泡泡上的非关键资源比如动画帧、缓存图片提升App的抗压能力。3. 实操过程与核心环节实现3.1 创建悬浮泡泡的完整代码我不喜欢写那种“概念正确但跑不起来”的伪代码下面这套代码是我在实际项目中整理、精简后保留下来的最小可运行版本基于Swift 5 iOS 14以上。它能实现创建一个全局悬浮泡泡支持拖拽、边缘吸附、点击展开回调并适配了横竖屏旋转。import UIKit class FloatingBubbleManager { static let shared FloatingBubbleManager() private var bubbleWindow: UIWindow? private var bubbleView: FloatingBubbleView! private var isDragging false private var dragStartPoint CGPoint.zero private init() { setupBubbleWindow() } private func setupBubbleWindow() { // 关键点windowLevel设置为normal1避免盖住状态下系统UI又能正常悬浮 let window UIWindow(frame: UIScreen.main.bounds) window.windowLevel UIWindow.Level.normal 1 window.backgroundColor .clear window.rootViewController FloatingBubbleRootVC() window.isHidden false bubbleWindow window bubbleView FloatingBubbleView(frame: CGRect(x: UIScreen.main.bounds.width - 74, y: 200, width: 56, height: 56)) bubbleView.backgroundColor .systemBlue bubbleView.layer.cornerRadius 28 bubbleView.clipsToBounds true bubbleWindow?.rootViewController?.view.addSubview(bubbleView) // 手势 let pan UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) bubbleView.addGestureRecognizer(pan) let tap UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) tap.require(toFail: pan) bubbleView.addGestureRecognizer(tap) } objc private func handlePan(_ gesture: UIPanGestureRecognizer) { guard let bubble bubbleView else { return } let translation gesture.translation(in: bubble.superview) switch gesture.state { case .began: isDragging false dragStartPoint gesture.location(in: bubble.superview) case .changed: let distance hypot(translation.x, translation.y) if distance 10, !isDragging { isDragging true } if isDragging { bubble.center CGPoint( x: bubble.center.x translation.x, y: bubble.center.y translation.y ) gesture.setTranslation(.zero, in: bubble.superview) } case .ended, .cancelled, .failed: if !isDragging { // 没有达到拖拽阈值视为点击走tap逻辑 return } snapToEdge() isDragging false default: break } } objc private func handleTap(_ gesture: UITapGestureRecognizer) { // 点击泡泡展开菜单或回调 print(浮动泡泡被点击) } private func snapToEdge() { guard let bubble bubbleView, let superview bubble.superview else { return } let bubbleWidth bubble.bounds.width let left superview.safeAreaInsets.left bubbleWidth / 2 let right superview.bounds.width - superview.safeAreaInsets.right - bubbleWidth / 2 UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseOut]) { if bubble.center.x superview.bounds.width / 2 { bubble.center.x left } else { bubble.center.x right } // 垂直方向做边界保护 let minY superview.safeAreaInsets.top bubbleWidth / 2 let maxY superview.bounds.height - superview.safeAreaInsets.bottom - bubbleWidth / 2 bubble.center.y min(max(bubble.center.y, minY), maxY) } } } // 用于承载泡泡的根控制器主要是为了处理屏幕旋转时泡泡位置的重新计算 class FloatingBubbleRootVC: UIViewController { override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() // 屏幕旋转时把泡泡拉回安全区域内 guard let bubble view.subviews.first else { return } let safeFrame view.bounds.inset(by: view.safeAreaInsets) if !safeFrame.contains(bubble.frame) { UIView.animate(withDuration: 0.3) { bubble.center.x min(max(bubble.center.x, safeFrame.minX bubble.bounds.width / 2), safeFrame.maxX - bubble.bounds.width / 2) bubble.center.y min(max(bubble.center.y, safeFrame.minY bubble.bounds.height / 2), safeFrame.maxY - bubble.bounds.height / 2) } } } } // 你可以在这个类里扩展泡泡的自定义外观和子视图 class FloatingBubbleView: UIView { // 这里可以加图标、角标、进度环等子视图 }上面代码里的setupBubbleWindow是核心整个悬浮的“地基”都在这里。有一点要特别注意根控制器一定要用自定义的空控制器而不是直接使用当前业务的ViewController。如果我们直接把泡泡加到某个业务页面的window上一旦页面销毁泡泡就没了。3.2 关键参数的选择逻辑有几个参数我是特意“调过”的这里单独提一下。第一个是泡泡尺寸。56x56pt是iPhone上悬浮按钮的黄金尺寸小于44pt不符合苹果HIG的最小点击目标要求大于64pt在全面屏手机上又会遮挡较多内容。如果你做的是视频类浮动窗尺寸可能要更大一些如80pt但需要同时把圆角调大视觉上才协调。第二个是吸附动画时长。0.25秒是苹果系统动画的默认时长这个值是让用户既感觉“跟手但不突兀”的临界点。我试过0.15秒太快视觉上有“拍”到墙上的生硬感试过0.4秒太慢用户会明显感觉卡顿。0.25秒配curveEaseOut是一种“靠近边缘时减速停稳”的效果手感最自然。第三个是拖拽阈值。10pt是我从多个系统控件比如UICollectionView的滚动判定中参考来的数值。这个值太小如2pt会导致轻微抖动就误判为拖拽太大会让拖拽响应显得迟钝。拇指按压屏幕时微小的抖动幅度通常在3-5pt之间10pt能有效避开这个区间。3.3 在已有项目中集成浮动泡泡如果你的项目已经有一套完整业务架构集成浮动泡泡只需要两步。首先在App启动时调用一次FloatingBubbleManager.shared触发懒加载把泡泡window挂到window层级中。main class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) - Bool { // 其他初始化代码... _ FloatingBubbleManager.shared return true } }其次把泡泡的点击回调与你自己的路由或页面跳转逻辑绑定。这一步我通常会用block或NotificationCenter解耦避免浮动模块反向依赖业务模块。比如点击泡泡时发送一个自定义通知由业务层决定是弹出菜单、跳转客服页还是切换播放状态。这里也提醒一下如果你的项目用了SceneDelegateiOS 13多窗口场景上面的代码要稍作调整。全局window的创建不能继续放在AppDelegate里而是由一个单例持有独立创建不依赖Scene的window。多窗口下是否显示浮动泡泡、多个Scene各自显示独立泡泡等细节建议在需求阶段就明确否则后期改动成本偏高。4. 常见问题与排查技巧实录4.1 键盘弹出后泡泡被顶起或遮挡这是浮动泡泡功能一个极易踩坑的场景。用户在输入框打字时键盘弹出如果泡泡悬浮在屏幕下半部分就会和键盘重叠。由于泡泡所在window的级别高于业务window泡泡反而会盖在键盘上面挡住键盘的数字键或字母键直接降低输入体验。我的处理方案是监听键盘通知在键盘弹出时把泡泡“驱逐”到键盘上方安全区域并在键盘收起时恢复。代码大致如下NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { [weak self] notification in guard let self self, let bubble self.bubbleView else { return } guard let keyboardFrame notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } let overlapRect bubble.frame.intersection(keyboardFrame) if overlapRect.height 0 { UIView.animate(withDuration: 0.25) { bubble.center.y keyboardFrame.minY - bubble.bounds.height / 2 - 10 } } }注意这里用的是keyboardWillShowNotification而不是keyboardDidShow因为前者能拿到动画曲线和时长配合UIView.animate可以实现和系统键盘同步的动画效果不会出现键盘已弹出但泡泡还在移动的延迟感。收起时的处理逻辑类似把泡泡恢复到之前的贴边位置即可。4.2 横竖屏切换后泡泡跑出屏幕横竖屏切换是所有悬浮控件最头疼的问题。屏幕尺寸一变原本贴边的泡泡位置可能直接超出安全区。在上面代码里我在FloatingBubbleRootVC的viewDidLayoutSubviews里做了位置修正但这个方法的触发时机是在布局完成后动画期间可能会有一次“跳变”。更好的做法是同时监听UIDevice.orientationDidChangeNotification在旋转动画完成后再做一次“回弹”处理。或者更彻底的方案是在旋转过程中让泡泡暂时保持当前相对位置不吸附旋转结束后再重新计算贴边方向。这里还有一个小细节如果你用UIWindow承载泡泡窗口本身的尺寸变化其实是跟随屏幕的不需要额外处理但如果用keyWindow获取屏幕尺寸来做计算在多窗口场景下可能会拿错。建议始终以bubbleView.superview?.bounds作为计算基准不要全局用UIScreen.main.bounds。4.3 手势冲突与点击失效我跟很多同行交流时发现大家反馈最多的就是“点击手势偶尔失灵”。排查下来大多数是手势代理方法没写全。比如你给泡泡添加了tap手势同时业务页面的滚动视图也对手势响应有影响由于泡泡window是独立的正常情况下不会冲突。但如果泡泡视图内部还嵌入了其他可交互控件比如一个UIControl子类或者一个小的关闭按钮事件分发就会被内部控件优先响应。我建议的做法是内部控件不要直接用系统UIControl而是把点击区域逻辑统一写在泡泡的touchesBegan/hitTest里管理。比如右上角的小红点关闭按钮其实是一个固定坐标的小热区当触摸点落在关闭热区内时执行关闭逻辑落在其他泡泡区域时执行主点击逻辑。这样就从根上避免了手势分发链的混乱。4.4 常见问题速查表现象可能原因排查/解决方向泡泡一闪就消失window未被强引用或window的isHidden被置为true检查单例持有尽量使用全局强引用拖拽时不跟手有延迟在pan手势中使用translation累计偏差注意每次setTranslation(.zero)重置点击无响应手势优先级导致tap被pan拦截使用require(toFail:)调整手势依赖键盘弹出后泡泡遮挡输入框未监听键盘通知监听keyboardWillShow并做避让横屏后泡泡消失未处理safeArea导致center越界在布局回调里修正位置内存持续上涨泡泡内部持有大图或数据模型闲置时释放非必要资源4.5 关于“是否能直接加载本地Vue打包项目到泡泡内”这类需求结合最近网上很多人在搜iOS本地加载Vue打包H5的问题这里顺带说一下浮动泡泡完全可以作为Web内容的容器比如做一个“悬浮浏览器”或“快捷面板”。你只需要在泡泡点击后弹出一个半屏WebView加载本地打包好的dist文件即可。iOS的WKWebView支持loadFileURL加载本地文件但记得要允许任意加载以解决跨域问题。如果你是要直接在泡泡内部显示一个常驻的迷你WebView也是可行的但要注意泡泡尺寸太小Web页面的交互体验会比较受限通常还是做成“点击泡泡 - 展开半屏面板”的形式更合理。另外有件事值得留意如果你计划用Charles或类似工具抓包调试泡泡内部的网络请求因为泡泡的window是全局的请求可能会发生在任意页面上下文中抓包时需要切换到对应页面才能看到正确的请求链路。调试气泡本身是否绘制正确建议打开“Debug - View Debugging”查看视图层级确认泡泡究竟挂在哪个window上。5. 实用扩展与封装建议5.1 让泡泡支持业务配置化如果你所在的团队不止一个项目需要这个功能建议把泡泡做成一个可配置的SDK。我第二次封装时对外暴露了以下配置项初始位置、尺寸、圆角、贴边方向、是否支持拖拽、是否显示关闭按钮、红点数字、点击回调。业务方只需传入一个配置模型就能生成自己需要的泡泡这样的架构在长期维护里收益极高。例如配置模型可以设计成下面的结构struct FloatingBubbleConfig { var initialPosition: CGPoint CGPoint(x: UIScreen.main.bounds.width - 74, y: 200) var size: CGSize CGSize(width: 56, height: 56) var cornerRadius: CGFloat 28 var supportedEdges: [BubbleEdge] [.left, .right] var dragEnabled: Bool true var badgeValue: Int 0 var tapAction: (() - Void)? }这样上层业务代码会非常清爽。接入方只需要调用FloatingBubbleManager.shared.show(with: config)不需要关心window怎么创建、手势怎么处理、键盘怎么避让。这正是封装的意义——把复杂留给自己把简单留给调用者。5.2 性能优化与耗电控制浮动泡泡是常驻控件耗电和性能影响虽然小但积累起来也不容忽视。我做了两个层面的优化一是泡泡默认静止时不启用任何定时器或CADisplayLink只有拖拽过程中才开启动画刷新二是泡泡的图层尽量扁平不要嵌套过多图层层级cornerRadius和clipsToBounds在早期机型上会触发离屏渲染在动画中尤其明显建议用CAShapeLayer切圆角或者直接用带圆角的背景图。如果你要在泡泡上展示一个转圈动画比如上传进度记得在动画结束时主动移除CAAnimation否则CALayer会持续占用资源。我已经反复验证过这套方案在iPhone 6s这种老设备上也能跑出60帧的拖拽流畅度说明优化空间无需过度设计但前提是别在泡泡上堆入过多的实时渲染内容。5.3 给新手的三个实操建议第一如果你第一次写严格按“独立Window - 空根控制器 - 子视图加手势”的顺序来。不要试图用业务页面自带的view实现后面一定会返工。第二不要在一个方法里写完所有手势逻辑。把手势处理拆分成begin/change/end三个函数能显著降低调试难度。哪怕代码行数多一点也比一个巨无霸switch看着省心。第三找一个低版本iOS的真机做验证。模拟器对手势、键盘、窗口层级的表现和真机有偏差尤其是键盘交互和系统手势冲突这类场景只有真机才测得出真实效果。我自己在项目里踩过的最后一个坑是在App退到后台再回到前台时泡泡的window偶尔会出现“层级错乱”的问题表现为泡泡突然不见了或者盖到了系统弹窗上面。解决办法是监听UIApplicationDidBecomeActiveNotification在回调里重新设置一次windowLevel并调用window.makeKeyAndVisible()这属于典型的“偏方治大病”但实测有效。如果你也遇到类似问题可以去查系统版本更新记录iOS 16之后对window层级的管理方式有一些细微变化多留意一下实机表现。本文还有配套的精品资源点击获取
返回列表