简介面向iOS开发者的电影选座功能实现方案源于类似猫眼电影选座的完整开发详解适合需要在App中快速落地选座场景的初中级开发者。内容涵盖Storyboard或SwiftUI搭建顶部电影信息、中间座位图与底部确认按钮的界面布局Seat与座位状态模型设计基于SMScrollView的滚动座位布局与点击拖动选座交互并通过不同颜色区分空闲、已选、禁选状态同时处理同排连续选座限制、多场次座位冲突以及JSON数据同步等业务逻辑。资源共110个文件压缩包约906KB以Objective-C源码.m/.h、Xcode工程配置.pbxproj、.xcscheme、.plist、PNG座位素材和说明文档为主目录结构完整便于对照学习。目前已有555人学习/下载。通过研读工程源码可掌握选座功能从数据模型到交互呈现、后台同步的完整链路也能学习到在项目中注明开源引用、致谢社区贡献的良好协作习惯。1. 电影选座这个功能最难的不是画座位图而是数据模型电影选座在票务类 App 里是典型的低频高复杂度页面看起来就是一张座位图加上几个按钮顶多支持缩放、拖动、选座、下单可真要自己复现一遍多数人折在座位数据结构和坐标换算上。网上流传的这份 iOS 选座 Demo按主流票务 App 的交互做了一套完整闭环——影厅座位图支持双指缩放与单指拖动、已售座位不可点、支持一次选多张票并做连座校验、锁定座位后有倒计时、超时自动释放。我把它从源码包到可运行工程完整过了一遍结论是这套工程适合刚接触 UICollectionView 定制布局、或者想直接抄一套能上线的选座方案的 iOS 开发者核心价值不在 UI 动画而在座位状态机的设计和订单链路对账。2. 座位数据模型与解析JSON 约定、状态机与坐标换算选座页面所有的展示和交互都建立在座位数据之上数据模型设计得糙后面画图、点选、下单全是坑。这份 Demo 的值得拆解之处是把座位数据结构、状态机、坐标换算三件事分开做每一层都留了扩展口。2.1 影厅 JSON 结构区域、排号与列号的约定主流票务 App 的影厅数据一般按「场次 → 影厅 → 区域 → 座位」四级组织。这份 Demo 用的不是深层嵌套而是扁平结构每个座位自带区域标识服务端一次下发整个场次的座位全量数据客户端按需过滤这在数据量只有几百个座位的影厅场景里是完全合理的。{ hallId: H1001, hallName: 1号激光厅, screenPos: 120, maxSelectCount: 6, seatList: [ {seatId: S1, row: 1, col: 1, area: IMAX, price: 55, state: 0}, {seatId: S2, row: 1, col: 2, area: IMAX, price: 55, state: 1}, {seatId: S3, row: 1, col: 3, area: IMAX, price: 50, state: 0} ] }几个字段的实际含义要提前说清楚。screenPos是银幕到第一排的垂直留白距离用来在座位图上方画一块屏幕区域取值 100140 之间比较合适。row和col从 1 开始递增中间可能跳过某些列跳过的列就是过道或者墙用地形来区分这是后面命中检测要处理的第一个边界。area是区域标识IMAX、杜比这类溢价区直接挂在座位上price也挂在座位上不做单独的区域价目表。我在实际对接中不推荐再拆一张区域价目表场次换价格策略时服务端只需要重发座位列表客户端不用做二次关联如果区域价格单独下发客户端要遍历座位重新计算容易出现两边价格不一致。另外seatId建议由服务端保证唯一客户端用「row col area」拼 id 的做法在特殊影厅里会撞车后面避坑章节会展开。2.2 座位状态机可选、已售、已选、锁定四种状态座位不是简单的「能不能点」它至少要有四种状态可选、已售、本地已选、被锁定。已售是服务端固化的本地不能改锁定期是指下单选座后服务器给某一批座位加的临时占用。这份 Demo 用枚举配合 Swift 值类型实现状态切换收敛在一个方法里。// 座位状态0 可选1 已售2 本地已选3 被锁定其他用户或订单占用 enum SeatState: Int { case available 0 case sold 1 case selected 2 case locked 3 } // 座位模型值类型方便局部刷新 struct Seat { let seatId: String let rowNo: Int let colNo: Int let areaId: String let price: Int var state: SeatState // 点选/反选只有可选和已选两个状态允许切换 mutating func toggleSelect() - Bool { guard state .available || state .selected else { return false } state (state .selected) ? .available : .selected return true } }逻辑上toggleSelect()是把一切状态切换收口的核心UI 层不直接改 state否则会出现「已售座位被点成已选」这种低级事故。这里有个细节值得注意.locked通常是服务端在用户下单时下发的本地拿到后要把座位从「本地已选」降级为「锁定」并触发整图刷新。状态机的边界用.rawValue和服务端数字对齐网络层解析 JSON 时直接可映射不需要中间字典。参数上price用Int存储单位是分不是元避免浮点误差。rowNo/colNo用Int做排序和相邻判断如果用字符串 A排3座 直接解析就麻烦了所以服务端数据源里应该保留纯数字排号、列号展示文案由客户端拼装。2.3 坐标换算从行列号到视图坐标系的统一入口座位图本质上是一张表格每个座位的视图位置由排号、列号和固定间距算出。这份 Demo 把计算收敛成SeatLayout结构体定义座位尺寸与间距常量并提供统一的坐标入口。// 座位布局常量单位 pt适配 375pt 宽屏幕按比例缩放 struct SeatLayout { static let seatWidth: CGFloat 32 static let seatHeight: CGFloat 28 static let gapH: CGFloat 4.0 // 列与列的水平间距 static let gapV: CGFloat 8.0 // 排与排的垂直间距 // 区域左上角偏移每个区域有自己的起始点 static func areaOrigin(areaId: String) - CGPoint { switch areaId { case IMAX: return CGPoint(x: 20, y: 60) case 杜比: return CGPoint(x: 180, y: 60) default: return CGPoint(x: 20, y: 140) } } // 计算某个座位的左上角坐标 static func origin(for seat: Seat) - CGPoint { let base areaOrigin(areaId: seat.areaId) let x base.x CGFloat(seat.colNo - 1) * (seatWidth gapH) let y base.y CGFloat(seat.rowNo - 1) * (seatHeight gapV) return CGPoint(x: x, y: y) } }间距参数直接决定座位图疏密gapH取 4pt 时座位间距紧凑适合普通厅遇到部分影厅两列之间有过道需要在服务端数据结构里增加skipColumns数组解析时跳过对应列而不是把 gap 调大。origin(for:)是所有绘制和命中检测的基础任何图层、手势、动画都通过这个入口取坐标避免各处自行计算导致偏移量不一致。3. 选座界面实现UIScrollView 缩放、命中检测与渲染界面层的核心矛盾是座位图要支持缩放和拖动又要保证点击命中准确。这份 Demo 没有用 UICollectionView 做网格而是直接用 UIScrollView 包裹一个内容视图在内容视图上摆放座位子视图这种方案在小规模座位图上实现成本最低、排错最直观。3.1 双指缩放与单指拖动UIScrollView 的标准方案UIScrollView 原生支持捏合缩放和拖动只要实现viewForZooming(in:)返回内容视图即可。关键点在于缩放结束后要不要重新布局座位子视图如果用了 Auto Layout缩放时约束会被缩放系数影响常见的做法是关闭内容视图的 autoresizing手动管理 frame。extension SeatMapViewController: UIScrollViewDelegate { // 指定缩放的视图为座位容器 func viewForZooming(in scrollView: UIScrollView) - UIView? { return seatContainerView } // 缩放结束后修正内容偏移避免缩放中心漂移 func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) { let minScale scrollView.minimumZoomScale let maxScale scrollView.maximumZoomScale // 限制缩放范围防止过度放大导致点选错位 scrollView.setZoomScale(min(max(scale, minScale), maxScale), animated: false) // 刷新座位图遮挡与选中标记 refreshSeatDisplay() } }minScale建议设为 0.8保证小屏设备能看到整厅座位maxScale设为 2.5 足够过大会让小厅的座位被放大到看不清排号。注意滚动手势和缩放手势是系统协调好的不需要自己写手势识别器但如果你在内容视图上再挂 tap 手势必须把cancelsTouchesInView设为 false否则单击会被滚动手势接管。3.2 命中检测把点击坐标映射回具体座位点击座位图时拿到的是触碰点坐标需要遍历座位列表检查点是否落在某个座位的矩形内。这份 Demo 在内容视图上添加 UITapGestureRecognizer手势回调里调用命中函数。objc func handleTap(_ gesture: UITapGestureRecognizer) { // gesture 添加在 seatContainerView 上location 就是内容视图坐标系 let point gesture.location(in: seatContainerView) guard let seat hitSeat(at: point) else { return } if seat.state .sold || seat.state .locked { showToast(该座位不可选) return } // 状态切换成功后刷新对应座位视图 if self.selectedSeats.canAdd(seat) { var seatCopy seat if seatCopy.toggleSelect() { applySelection(seatCopy) } } else { showToast(最多选择 \(self.maxCount) 个座位) } } // 命中检测遍历所有座位判断点是否在座位矩形内 func hitSeat(at point: CGPoint) - Seat? { for seat in allSeats where seat.state ! .sold { let rect CGRect(origin: SeatLayout.origin(for: seat), size: CGSize(width: SeatLayout.seatWidth, height: SeatLayout.seatHeight)) // 扩大 2pt 的点击容错范围 let hitRect rect.insetBy(dx: -2, dy: -2) if hitRect.contains(point) { return seat } } return nil }命中检测的两个参数细节第一insetBy(dx:-2, dy:-2)是把判定区域扩大 2pt座位间距小手指偏差常见没有这个容错用户会觉得「点不准」第二过滤掉.sold座位是性能优化已售座位数量多时逐点遍历更省时间。这个方案背后有个成本问题每次点击是 O(n) 遍历影厅 300 个座位时没问题超过 800 个就要考虑用空间索引比如按排分桶、按列二分。3.3 渲染策略颜色语义与已选标记座位视图用 UIView 自绘每个座位一个 32×28 的 view实际工程里不用担心几百个 view 的性能iOS 的视图树处理这个量级绰绰有余。颜色语义要跟主流票务 App 对齐可选座位浅灰底、已售座位深灰且不可点、选中座位橙色高亮、锁定座位透明占位。func applySelectedStyle(_ seatView: UIView, state: SeatState) { switch state { case .available: // 浅灰底 细边框表示可点 seatView.backgroundColor UIColor(hex: 0xE8E8E8) seatView.layer.borderWidth 0 seatView.isUserInteractionEnabled true case .sold: // 已售深灰填充不响应点击 seatView.backgroundColor UIColor(hex: 0xBFBFBF) seatView.isUserInteractionEnabled false case .selected: // 已选橙色高亮 seatView.backgroundColor UIColor(hex: 0xFF8C00) seatView.layer.borderWidth 1 seatView.layer.borderColor UIColor.white.cgColor case .locked: // 锁定透明占位 seatView.backgroundColor .clear seatView.isUserInteractionEnabled false } }这套颜色语义在产品层面有个约定已售与不可选必须视觉上明显区别如果都画成灰色用户会反复点同一批座位造成困惑。选中座位加白色描边是为了在已选较多时依然能分辨边界。注意到.locked座位没有编号文字因为它既不是已售也不是可用只表示「这排中间被订单占了」展示编号反而误导用户。4. 下单链路闭环锁座倒计时、价格联动与连座校验选座本身只是前端交互真正让它接近可上线的是下单链路。这份 Demo 处理了三个关键问题价格怎么跟着选中座位实时联动、锁定状态怎么在超时后释放、多张票是不是同一排相邻座位。4.1 价格联动与订单确认页的数据组装选中座位后底部工具栏要实时显示票数、总价和「选座购票」按钮状态。这里用了一个轻量级聚合结构把选中座位、票数、总价封装在一起避免在 VC 里散落维护。// 订单摘要根据已选座位实时计算 struct OrderSummary { let selectedSeats: [Seat] var count: Int { selectedSeats.count } var totalPrice: Int { selectedSeats.reduce(0) { $0 $1.price } } var isReadyToCheckout: Bool { count 0 count maxCount } } // 在 seatMap 页面内更新底部栏 func updateBottomBar(with summary: OrderSummary) { let totalYuan Double(summary.totalPrice) / 100.0 totalLabel.text String(format: 合计 ¥%.2f, totalYuan) countLabel.text 已选 \(summary.count) 张 checkoutButton.isEnabled summary.isReadyToCheckout }价格单位在数据模型里定的是分展示时统一转成元全程只在这个转换点出现Double别的地方都用Int相加。isReadyToCheckout限制了票数为 0 或超过上限时不亮按钮。这里有个产品决策选座数量上限来自场次字段maxSelectCount一般影厅限制 68 张超过上限后继续点击座位只弹提示、不选中。4.2 锁座倒计时与超时释放本地计时与服务端对账用户点击「选座购票」后客户端要锁住当前选中座位并启动倒计时。倒计时时间内座位不能被重新选择时间到了自动释放。这份 Demo 的思路是本地先锁、服务端确认LockManager 统一管理计时。final class LockManager { private var timer: Timer? private var lockExpiresAt: Date? // 启动锁座默认 15 分钟与主流票务 App 的支付时限一致 func startLock(duration: TimeInterval 900) { lockExpiresAt Date().addingTimeInterval(duration) timer?.invalidate() timer Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in self?.tick() } } // 每秒刷新倒计时 UI超时后回调释放 func tick() { guard let expiresAt lockExpiresAt else { return } let remaining expiresAt.timeIntervalSinceNow if remaining 0 { stopLock() onLockExpired?() // 通知页面释放本地已选座位 } else { onTick?(Int(remaining)) } } func stopLock() { timer?.invalidate() timer nil lockExpiresAt nil } }实现里有个工程习惯Timer用scheduledTimer(withTimeInterval:repeats:block:)闭包里要用[weak self]否则页面 pop 后计时器不释放造成内存泄漏。倒计时逻辑上有一个关键点本地超时只是释放 UI 状态服务端的锁座释放要靠接口通知所以回到选座页面时必须重新拉取座位状态不能只靠本地释放否则会出现「本地显示可点、服务端下单报已锁」的矛盾。4.3 连座校验同排相邻才算合法订单多数影厅选座要求同一订单的座位连在一起至少是同一排且中间无过道、无已售。这份 Demo 在添加座位时做校验代码如下。// 校验当前已选列表与候选座位是否构成连续同排座位组 func canAdd(_ candidate: Seat, to selected: [Seat], in allSeats: [Seat]) - Bool { // 第一条座位直接添加 guard let last selected.last else { return true } // 必须同一排 guard candidate.rowNo last.rowNo else { return false } // 列号距离超过 1 时检查中间是否有已售或锁定座位 let cols selected.map { $0.colNo } let minCol min(cols.min()!, candidate.colNo) let maxCol max(cols.max()!, candidate.colNo) for col in minCol...maxCol where col ! candidate.colNo !cols.contains(col) { // 中间空位不是过道且没有座位视为断档不允许 let blocked allSeats.contains { s in s.rowNo candidate.rowNo s.colNo col (s.state .sold || s.state .locked) } if blocked { return false } } return true }这里判断的核心是「已选集合里的座位加上候选座位在列号上必须连续」。中间如果有断档那断档处要么是过道要么是已售座位被跳过这两种情况都不允许继续选择。这也是为什么数据模型里过道要用「跳过的列号」表达而不是简单的列号递增——没有列号空洞就判断不了中间是否有障碍。注意这个函数只做同排连续校验跨排连座比如情侣厅的错位座需要另一套规则Demo 里默认不支持属于合理剪裁。5. 选座开发避坑五个真实翻车现场与排查记录这部分记录的是我在复现和改造这套 Demo 过程中实际遇到过的坑每条都按「现象 → 原因 → 解决」整理。选座这种强交互页面问题通常不在一行代码而在坐标系、状态同步和特殊影厅数据三个方向。5.1 点击座位总是偏移一排或者完全没反应现象在模拟器上点击座位选中的座位比实际点的位置偏左或偏上一格放大缩小后偏移更明显。原因tap 手势加在了 UIScrollView 上location(in:)拿到的是 scrollView 坐标系没有转换到内容视图坐标系。内容视图经过缩放和 contentOffset 偏移后两个坐标系原点完全不同。解决把手势加到seatContainerView上或者在手势回调里统一用gesture.location(in: seatContainerView)。从那以后我要求项目里所有手势的坐标一律用location(in: 内容视图)绝不在 scrollView 上直接取坐标。5.2 双指缩放时页面也跟着滚动座位图不动现象在座位图上双指捏合整个页面背景一起动座位容器没有缩放偶尔还会触发导航栏的下拉刷新。原因UIScrollView 的viewForZooming(in:)没有实现或者返回 nil系统不知道该缩放哪个视图于是把滚动和缩放事件都当成普通滑动处理。解决确认 delegate 已设置viewForZooming(in:)返回座位容器视图同时检查容器视图是否设置了固定的 contentSizeAuto Layout 下要保证容器有确定的宽高否则缩放范围计算不出来。复现时先在一个空工程里只放 scrollView 和 contentView 验证最小配置再往里面加座位。5.3 下单后提示座位已被锁定但本地明明显示可选现象用户选好座位提交订单服务端返回「座位已锁定」或「座位不可售」页面没有刷新这批座位依旧显示灰色可选。原因本地座位状态是进入页面时拉取的快照其他用户可能已经完成了这排座位的下单服务端状态已变客户端没有实时同步。解决每次从订单页返回时重新请求场次座位列表并强制刷新全部座位视图下单接口返回冲突错误时把出错座位立即标记为.sold或.locked并刷新。进阶做法是接入 WebSocket 或轮询接口场次开售前五分钟开启低频轮询但 Demo 里做到「下单失败后刷新对账」已经覆盖 90% 的场景。5.4 特殊影厅排位错乱VIP 厅两排之间间距忽大忽小现象同样的解析代码普通厅正常VIP 厅和情侣厅的座位图会出现两排间距特别大、座位缺失、列号不连续。原因特殊影厅的座位不是规则矩形排列VIP 厅有沙发间距情侣厅有双座合并服务端数据结构使用了不同的行列约定有的带gapRows、gapCols字段有的直接乱序下发。解决数据模型增加过道与间距声明比如服务端下发gapCols: [5, 12]表示第 5 列和第 12 列是过道解析时按空洞处理同时坐标计算要能读取每排的偏移量不能用一个全局gapH硬算。我在做这步时会让测试重点覆盖「特殊厅 小屏设备」组合这是最容易遗漏的回归场景。5.5 从订单页返回已选座位状态和价格全部清空现象选好座位进入订单确认页点返回继续选座之前选中的座位不再高亮底部栏金额归零。原因选座页面被 pop 后重新创建座位状态只存在页面内存里没有把已选座位列表交给订单页保存。解决用单例OrderContext持有已选座位数组和锁座管理器订单页读取同一个实例返回时从OrderContext恢复选中状态。注意恢复后要重新校验座位状态——如果服务端状态已变化不能盲目恢复先拉取最新数据再合并本地已选。6. 进阶验证用 XCUITest 把选座主流程跑成自动化回归手动点选座位的验证效率很低尤其连座校验、超时释放这类边界逻辑每轮改动都要重复操作。我给这套 Demo 补了一套 XCUITest 用例覆盖主流程和关键边界改动后跑一遍就能发现回归。func testSelectTwoSeatsAndCheckout() { let app XCUIApplication() app.launch() // 进入影厅 app.staticTexts[1号激光厅].tap() // 座位图容器需要设置 accessibilityIdentifier let seatMap app.otherElements[seatMapView] XCTAssertTrue(seatMap.waitForExistence(timeout: 3)) // 通过座位 id 精确点选相邻两座 app.otherElements[seat_S1].tap() app.otherElements[seat_S2].tap() // 底部工具栏金额应为两座之和 XCTAssertTrue(app.staticTexts[合计 ¥110.00].waitForExistence(timeout: 2)) // 进入订单页后返回选中状态应保留 app.buttons[选座购票].tap() app.navigationBars.buttons.firstMatch.tap() XCTAssertTrue(app.otherElements[seat_S1].isSelected) }测试要点有三个。第一每个座位视图必须设置accessibilityIdentifier seat_\(seatId)否则测试无法精确点选靠坐标点选在缩放后极不稳定。第二金额断言直接写死期望值同时覆盖了价格联动逻辑。第三返回后的选中状态断言正好回归了上一章的 5.5 号坑。我在实际维护中会再加一条边界用例连座校验失败时点击隔离座弹窗提示且座位不变色。每次改动坐标换算、状态枚举或 OrderContext 之后我会强制把这条 XCUITest 跑一遍再顺手人工测一遍特殊影厅的过道跳列。这个习惯帮我挡掉过至少三次「改间距常量导致普通厅正常、VIP 厅全歪」的回归。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?