本节摘要:植物列表是阳台花园的门面,UITableView 是 iOS 里最重要的展示器官。它不自己存数据,靠"数据源问答协议"问你要;它也不替你造每一行,靠"单元格复用"把上百行界面压成十几个模板的滚动。本节实现完整的植物列表页:注册单元格、实现两套协议、处理行高与左滑操作,并用一笔账讲清复用为什么是性能命脉。
第 3 章结束时园子里有了单页与导航,现在让内容长出来。需求:列表展示所有植物(名字、品种、倒计时),点进详情,左滑可标记已浇水。拆解成三件事:一个表格视图挂上视图树、一个数据源回答"多少行、每行长什么样"、一个委托处理"点了哪行、滑了什么"。
表格视图的世界观先立住:它是个面试官,不自己干活。滚动到哪行,它就问数据源两句话——"一共多少行?""这一行的单元格给我。"你实现协议回答它;答不上来,它一行都不画(第 6 章白屏清单第一名)。

数据源问答协议是 2.6 协议知识的第一次框架级应用——表格向你提问,你按契约作答:
final class PlantListViewController: UIViewController { private var plants: [Plant] = [] // 模型:唯一数据来源(3.1 的纪律) private let tableView = UITableView(frame: .zero, style: .insetGrouped) override func viewDidLoad() { super.viewDidLoad() title = "阳台花园" plants = PlantStore.shared.samplePlants() tableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tableView) NSLayoutConstraint.activate([ // 表格四边贴满安全区 tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), ]) tableView.register(UITableViewCell.self, // 注册模板:系统标准单元格 forCellReuseIdentifier: "plant") tableView.dataSource = self // 授权:数据源是我 tableView.delegate = self // 授权:委托也是我 } } extension PlantListViewController: UITableViewDataSource { // 必答问一:多少行 func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return plants.count } // 必答问二:这行的单元格(复用借还都在这一步发生) func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "plant", for: indexPath) let plant = plants[indexPath.row] // 行号即数据下标 var content = cell.defaultContentConfiguration() content.text = plant.name // 主标题 content.secondaryText = "\(plant.kind.rawValue) · \(plant.statusText)" // 副标题 cell.contentConfiguration = content cell.accessoryType = .disclosureIndicator // 右侧箭头,暗示"能点进去" return cell } } extension PlantListViewController: UITableViewDelegate { // 选修问三:点了哪行 → push 详情(3.6 的主干) func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) // 取消高亮,观感细节 let detail = PlantDetailViewController(plant: plants[indexPath.row]) navigationController?.pushViewController(detail, animated: true) } // 选修问四:行高 func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { return 56 } // 选修问五:左滑动作——标记已浇水 func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { let water = UIContextualAction(style: .normal, title: "已浇水") { [weak self] _, _, done in self?.plants[indexPath.row].water() // 模型推进 tableView.reloadRows(at: [indexPath], with: .automatic) // 只刷这一行 done(true) } water.backgroundColor = .systemBlue return UISwipeActionsConfiguration(actions: [water]) } }
运行输出(点按与左滑的控制台追踪):didSelectRowAt 的 indexPath.row 是 2、左滑后该行副标题变成"多肉 · 还剩 10 天"——模型改了、局部刷新了,两步缺一不可。改了模型不刷新,界面纹丝不动;只刷新不改模型,闪一下又弹回。这是第 6 章排错清单的常客。
背景:花园刚装好时一株植物都没有,空白表格很挫败。操作:数据源问答协议天然适合表达空态——行数为零,表格自动显示空;再加一句文案。
// 空态三件套:标题、说明、引导动作 func installEmptyState() { let empty = UILabel() empty.text = plants.isEmpty ? "花园还空着\n点右上角 + 添加第一株植物" : nil empty.numberOfLines = 0 empty.textAlignment = .center empty.textColor = .secondaryLabel empty.translatesAutoresizingMaskIntoConstraints = false view.addSubview(empty) NSLayoutConstraint.activate([ empty.centerXAnchor.constraint(equalTo: tableView.centerXAnchor), empty.centerYAnchor.constraint(equalTo: tableView.centerYAnchor), ]) empty.isUserInteractionEnabled = false // 别挡住底下的表格手势 } // 结果:零行时表格区域中央显示引导文案;添加首株后 plants 非空,文案逻辑置 nil
解读:空态放在表格之上、交互穿透关闭——既看得见又不挡滚动;文案与数据同源判断,不会出现"有数据还显示空态"的矛盾。变式:更讲究的空态是一张插画加按钮,甚至用表格的背景视图承载;核心不变——空态是数据源协议的自然延伸,不是补丁。
💡 关键直觉:表格开发的心法是"数据是唯一真相,单元格只是快门"。任何界面状态都先改 plants 数组,再让刷新机制拍一张新照片;反着来(直接改单元格文字)迟早与数据失同步。
行是排整齐了,"收藏"页想摆成卡片网格——下一节 UICollectionView。