Swift+UIKit实现猫眼风格选座:座位渲染到订单提交的完整闭环

发布时间:2026/10/20 11:50:02
Swift+UIKit实现猫眼风格选座:座位渲染到订单提交的完整闭环
简介一份iOS电影选座功能实现源码包面向需要开发票务类或影院App选座模块的开发者以类似猫眼App的选座流程为蓝本完整覆盖界面布局、数据管理、座位交互与网络同步等环节。项目使用自定义滚动视图承载座位图通过座位数据模型管理可选、已选、禁选状态自定义座位视图负责点选手势、连坐限制和选中高亮等反馈。资源共110个文件压缩包仅约906KB包含28张界面图片、13个m源码、9个头文件、12个工程配置方案、9个属性列表及5个json数据文件另有依赖管理、编译配置、脚本等辅助内容。已有555人前来学习。深入阅读这套代码可重点理解滚动座位图的绘制与适配、多场次座位占用冲突处理以及通过Core Graphics自绘座位样式的实现技巧同时学习工程结构组织方式对提升自定义控件与交互设计能力很有帮助。1. 类似猫眼电影选座(iOS)把一张座位图做成完整的闭环做过票务类 App 的人都知道选座页是全链路里最容易翻车、也最影响转化的页面。用户从场次点进来真正关心的其实只有三件事哪些座位能选、能不能连座、价格怎么算任何一个环节卡住都可能丢单。这个项目就是一套参考猫眼选座交互的 iOS 完整实现从座位图渲染、点击选座、价格联动、限选规则到生成下单参数整条链路都真实跑得通。它适合两类人一类是想搞懂自定义视图绘制、手势命中与状态管理的 iOS 开发者另一类是产品原型期需要先交出一个可点可选选座 Demo、不等后端接口就能给业务方演示的开发者。工程不依赖任何第三方库纯 Swift UIKit 手写拿到后直接在 Xcode 里编译运行没有任何中间环节。2. 技术选型与工程结构为什么是纯 UIKit而不是 SwiftUI2.1 Swift UIKit选座场景对视图层的要求先回答一个大概率会被问的问题都做到这个年头了为什么不用 SwiftUI我的看法是选座页本质上是一张「可以缩放、可以平移、上面散落着几百个可独立变化状态的小部件」的画布。SwiftUI 做列表和表单确实顺手但一旦涉及 UIScrollView 缩放、精确命中区域、以及高频的选中态切换UIKit 的显式视图树反而更好控制——每个座位就是一个独立的 UIControl点谁谁响应系统帮你把命中测试都处理完了。这个 Demo 的架构是轻量 MVVM没有引入 RxSwift 之类的响应式框架而是用「ViewModel 暴露方法、View 通过闭包回调事件」的经典写法。这样做的原因有三个第一座位状态变化是典型的多视图联动——点一个座位顶部提示文案、底部价格条、座位图本身要同时刷新状态集中到 ViewModel 里可以避免控制器里塞满 if第二后续接真实接口时只需要把 SeatService 的 mock 数据源换掉View 层一行都不用动第三ViewModel 不依赖 UIKit可以直接写单元测试验证选座规则这在票务这种规则三天两头变的业务里非常值钱。2.2 工程目录每个文件负责一件事工程按功能拆分不是按层拆分找代码的时候顺着页面走就行SeatDemo/ ├── App/ │ ├── AppDelegate.swift # 启动入口创建窗口 │ └── SceneDelegate.swift ├── Models/ │ ├── Movie.swift # 影片信息 │ ├── Showtime.swift # 场次与影厅维度 │ ├── Hall.swift # 影厅座位矩阵描述 │ ├── Seat.swift # 座位模型与状态枚举 │ └── Order.swift # 订单草稿与下单参数 ├── ViewModels/ │ └── SeatSelectionViewModel.swift # 选座规则、选中集合、金额计算 ├── Views/ │ ├── SeatMapView.swift # 座位图画布负责铺座与缩放 │ ├── SeatButton.swift # 单个座位按钮渲染与命中 │ ├── SeatLegendView.swift # 图例可选/已售/选中/过道 │ ├── PriceBarView.swift # 底部价格条与确认按钮 │ └── FilmStripView.swift # 顶部影片与场次切换条 ├── ViewControllers/ │ └── SeatSelectionViewController.swift # 页面装配与回调绑定 ├── Services/ │ └── SeatService.swift # mock 数据源模拟接口延迟 └── Resources/ └── seat_theme.json # 配色与价格分区配置简单过一遍职责SeatService 是数据源的黑匣子边界内部用 DispatchQueue.asyncAfter 模拟 300ms 网络延迟返回场次、影厅和座位数组SeatMapView 只做两件事——根据座位矩阵生成按钮、把点击事件通过闭包抛出去自己不保存任何业务状态SeatSelectionViewModel 持有座位矩阵和选中集合所有规则都在这里ViewController 只负责把三者串起来同时处理弹窗、loading 这类 UI 细节。这种分层方式最直接的好处是出问题时能快速定位按钮显示不对查 View规则不对查 ViewModel数据不对查 Service。关键类职责对外接口SeatMapView渲染座位矩阵、缩放平移容器configure(with:)、onSeatTap 闭包SeatButton单座按钮皮肤与命中外抛seat 属性、isSelected 刷新SeatSelectionViewModel限选、取消、金额、快照toggle(_:)、selectedSeatIds、totalAmountSeatServicemock 影厅与座位数据fetchSeats(showtimeId:completion:)SeatSelectionViewController视图装配、提交订单confirmTapped()、showOrderConfirm(_:)这个表基本就是二次开发的索引想改选座规则直接看 ViewModel想改座位长什么样进 Views 目录想接真实数据只动 SeatService。3. 核心模块拆解座位模型、渲染与命中测试3.1 座位数据模型状态为什么不用一个 Bool座位模型是整个页面的地基先看它长什么样enum SeatStatus: Int, Codable { case sold 0 // 已售出灰色不可点 case available 1 // 可售可点击 case aisle 2 // 过道排内占位不渲染 case locked 3 // 他人锁定几分钟内未支付 } struct Seat: Codable, Equatable { let seatId: String // 唯一标识规则排号_列号如 A_12 let rowNo: Int // 排号索引从 1 开始 let colNo: Int // 列号索引从 1 开始 let zone: Int // 票价分区1 普通区 / 2 黄金区 / 3 情侣座区 let price: Int // 单座价格单位是分避免浮点误差 let status: SeatStatus // 服务端返回的实时状态 }逻辑说明状态用枚举而不是 Bool 的原因很实际——「不能选」至少分三种情况已售完、过道、别人正在锁定。如果只用一个 isAvailable: Bool过道座和已售座就没法区分渲染时要么错位要么会多画出一排空洞。价格用 Int 分而不是 Double 元是票务项目最常规的做法金额计算只做整数加减不会出现 0.1 0.2 这种经典型的精度坑。参数说明seatId 用排号加列号拼接主要是为了排查问题方便——用户反馈「第 6 排最左边点不动」看日志里打印的 seatId 立刻能对上位置。zone 字段是给后续做分区配色预留的当前版本只在图例和价格条里体现后面要做「点击某分区高亮所有同价座位」这个字段就是现成的索引。3.2 渲染375 设计稿铺座用比例缩放适配机型SeatMapView 用 UIScrollView 包一个容器视图所有座位都铺在容器上布局核心就一段/// 座位尺寸与间距按 375pt 宽设计稿定稿 private let seatSize CGSize(width: 36, height: 36) private let hGap: CGFloat 8 private let vGap: CGFloat 14 private let designBaseWidth: CGFloat 375 private func layoutSeats(on container: UIView) { var contentHeight: CGFloat 0 for (rowIndex, row) in matrix.enumerated() { let y CGFloat(rowIndex) * (seatSize.height vGap) topPadding for (colIndex, seat) in row.enumerated() { guard seat.status ! .aisle else { continue } // 过道不落按钮 let zoneOffset CGFloat(seat.zone - 1) * 6 // 分区错位 6pt let x CGFloat(colIndex) * (seatSize.width hGap) zoneOffset let button SeatButton(type: .custom) button.frame CGRect(x: x, y: y, width: seatSize.width, height: seatSize.height) button.seat seat button.addTarget(self, action: #selector(seatTapped(_:)), for: .touchUpInside) container.addSubview(button) } contentHeight y seatSize.height } container.frame.size CGSize(width: designBaseWidth, height: contentHeight) } /// 不同屏幕用等比缩放避免小屏重叠、大屏被拉伸成椭圆 func applyScreenScale(to container: UIView) { let scale UIScreen.main.bounds.width / designBaseWidth container.transform CGAffineTransform(scaleX: scale, y: scale) scrollView.contentSize CGSize(width: designBaseWidth * scale, height: container.frame.height * scale) }逻辑说明布局用「行索引 ×座位高 行距」算 y用「列索引 ×座位宽 列距」算 x是最朴素的网格铺法。zoneOffset 让不同价位的分区在视觉上错开一点模拟真实影厅里分区之间有走道的感觉。按钮统一用 .custom 类型因为系统默认按钮自带高亮背景和点击态会影响自定义座位皮肤。参数说明36pt 的座位在 375pt 宽屏幕上大约可以放 8 个带间距竖屏场景下影厅宽度刚好够vGap 14pt 比水平间距大一圈是为了在视觉上明确区分「排」和「列」。整体 scale 用的是「屏幕宽 / 设计稿宽」等比缩放而不是逐点拉伸避免在 6.7 寸大屏上座位被拉成椭圆这是选座页最常见的变形翻车原因。3.3 命中测试点到的到底是哪个座位每个座位都是独立的 UIControl命中测试直接交给系统这是 UIKit 做选座页一个隐性好处。按钮实现final class SeatButton: UIButton { var seat: Seat? // 绑定的座位数据点击时回抛给外部 override var isSelected: Bool { didSet { refreshAppearance() } } private func refreshAppearance() { guard let seat else { return } switch (seat.status, isSelected) { case (.available, false): setBackgroundImage(UIImage(named: seat_normal), for: .normal) case (.available, true), (.locked, true): setBackgroundImage(UIImage(named: seat_selected), for: .normal) case (.sold, _): setBackgroundImage(UIImage(named: seat_sold), for: .normal) isUserInteractionEnabled false default: break } accessibilityLabel \(seat.rowNo)排\(seat.colNo)号座位 } }逻辑说明SeatButton 持有 seat 数据外观由「服务端状态 本地选中态」共同决定。isSelected 是 UIButton 自带的属性选中变化时触发 didSet 刷新图片ViewModel 只要切换选中集合按钮外观自动跟着变不需要手动 setImage这也是把状态收敛到 ViewModel 带来的连锁收益。参数说明isUserInteractionEnabled 置 false 的座位会直接从响应链里摘出去连高亮动画都不会触发这是处理已售座位的正确姿势而不是在回调里加 if 判断。顺手把 accessibilityLabel 做了VoiceOver 用户能听到「第几排第几号」这类辅助功能字段看起来不起眼等 App 走审核或合规检查时就是硬指标。4. 选座状态流转ViewModel 怎么管住几百个座位4.1 选座规则限选、重复点击与金额联动选座交互的规则不多但每一条都直接影响用户体感。核心逻辑全部在 ViewModel 里struct SeatSelectionViewModel { let matrix: [[Seat]] // 服务端返回的原始座位矩阵 let maxSeats: Int // 单笔订单最多可选座位数 private(set) var selected: [Seat] [] // 本地选中集合按点击顺序 mutating func toggle(_ seat: Seat) - Result[Seat], SeatError { guard seat.status .available else { return .failure(.notAvailable) } if let index selected.firstIndex(where: { $0.seatId seat.seatId }) { selected.remove(at: index) // 再点一次取消选中 return .success(selected) } guard selected.count maxSeats else { return .failure(.exceedLimit) } selected.append(seat) return .success(selected) } var totalAmount: Int { selected.reduce(0) { $0 $1.price } } var selectedSeatIds: [String] { selected.map { $0.seatId } } } enum SeatError: Error { case notAvailable // 已售或锁定不可选 case exceedLimit // 超过限选数量 }逻辑说明toggle 是整套选座逻辑的唯一入口成功返回新的选中数组失败返回错误类型。注意「取消选中」的判断在「限选数量」之前所以当用户已经点了 6 个座位后想换一个必须先点掉旧座再点新的——实际产品里也可以改成「超限时自动顶掉最早选的那个」两种策略见仁见智Demo 保留了最直观、用户最能理解的写法。参数说明selected 用数组而不是 Set是为了保留点击顺序后续做连坐判定和自动推荐都要依赖这个顺序。totalAmount 每次调用都重新 reduce而不是维护一个增量变量是刻意为之——选座操作虽然高频但座位最多也就几十个全量重算的开销可以忽略却换来「永远不可能出现金额状态不同步」的确定性。4.2 下单座位快照与重复提交防护点击底部「确认选座」后页面要把当前选中座位打包成下单参数struct OrderDraft: Encodable { let showtimeId: String // 场次 ID let hallId: String // 影厅 ID let seatIds: [String] // 座位 ID 快照如 [A_5, A_6] let totalAmount: Int // 总金额单位分 let orderSource: String // 渠道标识用于后端埋点 } final class SeatSelectionViewController: UIViewController { private var isSubmitting false // 提交锁防止连点 objc private func confirmTapped() { guard !isSubmitting else { return } isSubmitting true let draft OrderDraft( showtimeId: viewModel.showtime.id, hallId: viewModel.showtime.hall.id, seatIds: viewModel.selectedSeatIds, totalAmount: viewModel.totalAmount, orderSource: ios_seat_demo ) orderService.submit(draft) { [weak self] result in self?.isSubmitting false // 成功跳转支付占位页 / 失败弹出重试 } } }逻辑说明提交时做三件事——用 isSubmitting 锁住入口、把选中座位拍成快照、走 orderService 提交。快照的意义在于提交过程中用户仍然可以操作页面但传给后端的永远是点击确认那一瞬间的座位列表不会出现「界面显示选了 A5、实际提交了 A6」的错位。参数说明isSubmitting 是防重提交最简单的手段比「按钮 disable 再恢复」更稳因为异步回调回来时页面状态可能已经变了。orderSource 这种字段看起来不起眼但线上排查订单来源、区分 App 端和后续的 H5 渠道全靠它属于一开始就该留好的字段后面补会非常痛。5. 跑起来与避坑指南从 Xcode 到真机调试5.1 环境要求与启动步骤工程是纯 Swift 工程没有任何第三方依赖因此省掉了 CocoaPods 和 SwiftPM 这一层解压就能编。环境要求按当前主流 Xcode 版本即可Xcode 14 以上、iOS 15 以上模拟器、Swift 5.7 以上。启动流程分四步解压工程后双击 SeatDemo.xcodeproj 打开 Xcode。如果提示签名问题在 Signing 里选一个自己的开发团队即可模拟器运行不要求真机签名。在 Xcode 顶部 Scheme 里把运行目标选成一个 iPhone 模拟器然后 Command R 编译运行。进 App 后先出现场次列表这是 SeatService 用 mock 数据生成的点击任意场次进入选座页座位图会模拟 300ms 网络延迟后渲染出来。想从 mock 切换到真机联调时只需要替换 SeatService 内部实现接口路径和参数都写在文件注释里照着改就行。模拟器上有一个小技巧座位图支持双指缩放模拟器里按住 Option 键就能模拟双指手势调试大影厅座席时非常方便不用非得找真机。5.2 避坑排查五个真实踩过的坑下面五条是从开发到联调阶段反复遇到的问题每一条都按「现象 → 原因 → 解决」整理。坑一点击座位选中的却是旁边一个现象用户点第 5 排 3 号高亮却落在 4 号上小屏机型尤其明显。原因座位容器包在 UIScrollView 里容器又设置了 transform 缩放。点击坐标要经过 scrollView 的内容偏移和 transform 两层折算任何一层没处理对命中就偏。解决把命中交给按钮自身不要自己用坐标换算。现在每个座位是独立的 UIControl事件由系统分发。如果遇到必须手写命中测试的场景记得先把 point 用convert(_:from:)转换到按钮父视图坐标系再判断frame.contains(point)。从那以后我检查所有带缩放的容器只要涉及触摸一律先做坐标转换这类错位问题就基本绝迹了。坑二快速连点两次产生了两个订单现象提交后页面跳转有延迟用户手快又点了一次后端收到两笔相同订单。原因按钮 disable 的生效是异步的第一次点击的回调还没回来第二次点击已经进来了。解决用 isSubmitting 布尔锁而不是 disable 按钮它跟 UI 状态无关天然防重。另外可以在 OrderDraft 里补一个 clientRequestIdUUID 字符串后端拿它做幂等同一个请求号只创建一笔订单这是从服务端层面兜底的做法前后端各守一道。坑三6.7 寸大屏上座位被拉成了椭圆现象大屏真机上座位横向间距明显比设计稿大整排座位看起来被横向抻开。原因页面用了 Auto Layout 约束把座位宽度跟着屏幕宽度撑开了相当于每个座位横向等比变大视觉上就成了椭圆。解决固定 375pt 设计稿宽度整体 transform 等比缩放也就是 3.2 节 applyScreenScale 的思路。缩放之后要记得同步更新 scrollView 的 contentSize否则滚动范围算错最边上的座位滚不到。坑四刚进页面时已售座位没问题过一会儿却能选中现象进入页面后已售座位是灰的但停留十几秒后再点有的座又能选中了。原因前端只在首次加载时拉了一次座位状态这段时间别人买走或被锁定的座位本地完全没有感知。解决选座页通常不主动刷新——刷新会把用户正在选的座位状态打掉体验更差。正确做法是进页面时拉一次全量状态提交订单时后端做最终校验前端收到座位冲突错误码就提示「座位已售出」并刷新本地状态。把最终一致性放在提交那一跳是所有票务项目的通用妥协方案。坑五VoiceOver 读不出座位排号现象开启旁白后座位只朗读「按钮」用户根本分不清自己在选第几排。原因自定义按钮没有设置 accessibilityLabel系统只能读出控件类型。解决在 SeatButton 里给每个座位设置 accessibilityLabel内容就是「第几排第几号」顺手把已售座位的 accessibilityTraits 标记为 .notEnabled旁白用户就不会点到已售座位上。6. 改造思路加「智能推荐选座」和分区高亮6.1 改造一一键选中间排连座产品上有个高频需求帮用户「选最好的位置」。实现思路是定位影厅中线排从中线向两侧扩展找连续可售座位func suggestSeats(in matrix: [[Seat]], count: Int) - [Seat] { guard !matrix.isEmpty, count 0 else { return [] } let centerRow matrix.count / 2 for rowOffset in 0...3 { // 从中间排向外扩散最多 3 排 for rowIndex in [centerRow rowOffset, centerRow - rowOffset] { guard matrix.indices.contains(rowIndex) else { continue } if let seats findContinuous(in: matrix[rowIndex], count: count) { return seats } } } return [] } private func findContinuous(in row: [Seat], count: Int) - [Seat]? { var streak 0 var startIndex 0 for (index, seat) in row.enumerated() { if seat.status .available { if streak 0 { startIndex index } streak 1 if streak count { return Array(row[startIndex...index]) } } else { streak 0 } } return nil }逻辑说明找连座本质是一个滑动窗口问题在单排里数连续 available 的座位攒够 count 个就返回。外层从中间排向上下扩展优先满足「位置居中」的直觉。这个函数是纯计算不碰 UI可以直接塞进 SeatSelectionViewModel 里配合单测覆盖各种边界。参数说明rowOffset 上限 3 表示最多向外找 3 排再远就失去「推荐好位置」的意义了。返回数组按排内顺序排列调用方拿来替换 selected 集合即可再让座位图刷新。改造时确认一个边界如果用户已经手动选了 2 个座再点推荐是追加还是替换建议做成替换并弹确认提示不然会把用户已有的选择静默冲掉。6.2 改造二按价格分区高亮整片座位另一个常见交互是「点击价格条某档位同价座位全部高亮」。这个改动不需要碰渲染层只需在 SeatSelectionViewModel 里加一个highlightZone: Int?属性SeatButton 的 refreshAppearance 在读图之前判断当前座位的 zone 是否匹配匹配就叠加一层半透明遮罩。业务状态与视觉状态彻底解耦分区高亮和选中态互不覆盖。验证方法分两层逻辑层用单元测试直接调 ViewModel 的 toggle 和 suggestSeats断言选中集合、限选次数、金额分项渲染层用 XCUITest 跑一条完整用例——进选座页、点两个座位、断言底部价格条文本等于两个座位价格之和、点确认、断言跳转到支付占位页。选座这种页面UI 自动化前期投入不高但后续每改一次规则都能自动回归非常值钱。最后说一个我自己的习惯凡是带手势和状态联动的页面每次改动后我都会强制走一遍完整流程——小屏、大屏、横竖屏各点一遍再开着 VoiceOver 过一遍选座和取消。因为选座页的 bug 从来不藏在代码编译错误里而是藏在坐标换算、状态时序和辅助功能这些最容易忽略的角落。这个工程把这些典型坑都提前趟了一遍拿到手先按第 5 章的步骤跑通再动手改希望帮到你。本文还有配套的精品资源点击获取