简介:面向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是银幕到第一排的垂直留白距离,用来在座位图上方画一块屏幕区域,取值 100~140 之间比较合适。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,一般影厅限制 6~8 张,超过上限后继续点击座位只弹提示、不选中。
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:)返回座位容器视图;同时检查容器视图是否设置了固定的 contentSize,Auto 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 厅全歪」的回归。希望帮到你。
本文还有配套的精品资源,点击获取