为 Apple Watch 写一个校巴时刻表工具
2020 年 WWDC,苹果更新了 SwiftUI 的第二版。我惊呼:Swift 终于能用了!这次我们不写 Web 和小程序!这是一篇 Native Code 的教程。
一转眼,SwiftUI 已经推出一年了。随着这个框架的流行,苹果软件开发的门槛也进一步降低,低到我也能踮踮脚够进去了。这个暑假,由于疫情的原因,原定去新加坡国立大学的暑期交流取消了。于是,计划已久的 SwiftUI 探寻就提前开始了。
目标简介
迫于在南科大,查询校巴的发车时间过于麻烦,因此写了一个 Apple Watch APP,用于查询南科大校园巴士最近的发车时间。作为 Swift 和 SwiftUI 的上手项目,代码逻辑非常简单,仅用于练习。项目完整代码已经上传到 GitHub。
目前南科大的校巴一共有两条线:上山线(终点欣园)和下山线(终点科研楼)。在这个手表 APP 中,要分别显示这两个线路校巴运行的情况。南科大的校巴运行时刻表也有两个版本:工作日版和节假日版。在 APP 中我们要能灵活切换时刻表。
开始做之前
先谋划一下开发的架构。尽管这个应用实在是太简单 —— 简单到所有代码可以放在一个文件里,但是那样做并不是一个好的开始。
SwiftUI 不同于往日的 UIKit,它并不主张 MVC (Model, View, Controller) 的模型,而是主张 MVVM (Model, View, View Model)。也就是,并不存在一个 Controller 可以控制 View 的进行。** 用 SwiftUI 就是戴着镣铐舞蹈。** 它很强大,但不能用来随心所欲干所有的事。这点我深有体会。
Model 是对数据的抽象。在这个应用中,它应该被封装成校巴的时刻表。作为第一个 SwiftUI 应用,一切从简,我们就不去触碰数据管理(File System, Core Data, UserDefault, …)了,而是将所有运行时刻直接硬编码到代码中。
View 是我们需要构建的 UI 界面。我已经提前设计好了界面:
界面主要是文字构成,辅以两个 SF Symbol 图标。在 watchOS 7.0 中,苹果取消了重压操作,所以我们把两个功能按钮藏在主界面下面。
View Model 是一个有趣的概念。我们应该在 View Model 里把 Model 中的数据进一步转化成 View 中所需要的。此外,我们应该在这里列举所有用户可能的意图 (intents)。例如点击 “更新时间”,或者 “变更运行模式”。
开始做!
Model: Bus.swift
先来写最简单的 Model。我们需要得知当前时间之前和之后的两趟巴士发车时间。
//在这里hardcode日程表,省略了部分数据。
let XinYuan: [String] = ["07:20", "07:25", ... , "22:00", "22:10", "22:30"]
let KeYanLou: [String] = ["07:00", "07:05", ..., "21:20", "21:30", "21:40"]
let XinYuanHoliday: [String] = ["07:20", "07:40", ..., "21:40", "22:00", "20:20"]
let KeYanLouHoliday: [String] = ["07:00", "07:20", ..., "21:20", "21:40", "22:00"]
private func getBusSchedule(_ current: Date, schedule: [String]) -> (String?, String?) {
var previous: String?
var next: String?
for time_string in schedule {
let time = dateFormatter.date(from: time_string)!
if Time(time) <= Time(current) {
previous = time_string
} else {
next = time_string
return (previous, next)
}
}
return (previous, next)
}
func getXinYuanBus(_ current: Date, weekday isOnWeekDay: Bool) -> (String?, String?) {
if isOnWeekDay {
return getBusSchedule(current, schedule: XinYuan)
} else {
return getBusSchedule(current, schedule: XinYuanHoliday)
}
}
这里我写了一个 Time 类用于忽略 Date 类的日期数据,只比较时间。更优雅的方式是给 Date 写 extension,但这里没有做。
class Time: Comparable {
static func <(lhs: Time, rhs: Time) -> Bool {
lhs.hour == rhs.hour ? lhs.minute < rhs.minute : lhs.hour < rhs.hour
}
static func ==(lhs: Time, rhs: Time) -> Bool {
lhs.hour == rhs.hour && lhs.minute == rhs.minute
}
var hour: Int
var minute: Int
init(_
let calendar = Calendar.current
let components = calendar.dateComponents([.hour, .minute], from: date)
self.hour = components.hour!
self.minute = components.minute!
}
}
ViewModel: BusViewModel.swift
之后来写 View Model。View Model 主要负责把数据处理成方便 View 读取的形式(这样也方便测试)。我们主要用 “计算属性”(Computed Property) 来实现。
var currentTime: String {
dayFormatter.string(from: currentDate)
}
var XinYuanPrevious: String {
bus.getXinYuanBus(currentDate, weekday: isOnWeekDay).0 ?? "空"
}
var XinYuanNext: String {
bus.getXinYuanBus(currentDate, weekday: isOnWeekDay).1 ?? "空"
}
var KeYanLouPrevious: String {
bus.getKeYanLouBus(currentDate, weekday: isOnWeekDay).0 ?? "空"
}
var KeYanLouNext: String {
bus.getKeYanLouBus(currentDate, weekday: isOnWeekDay).1 ?? "空"
}
我们还需要能切换运行模式的功能,这里用变量的 get/set 捕获来实现。
var isChange: Bool = false
var changedValue: Bool?
var isOnWeekDay: Bool {
get {
if !isChange {
return !calender.isDateInWeekend(currentDate)
}
else {
return changedValue!
}
}
set {
self.isChange = true
self.changedValue = newValue
}
}
mutating func swichModel() {
isOnWeekDay.toggle()
}
View: BusView.swift
最后我们用 SwiftUI 来构建界面。首先构造主体部分。
用 HStack 和 VStack 控制界面的嵌套。
struct BusTableView: View {
var direction: String
var previous: String
var next: String
var iconName: String
var body : some View {
HStack {
Image(systemName: iconName)
.font(.title)
.padding(.trailing)
VStack(alignment: .leading) {
Group {
Text("\(direction)方向")
.font(.headline)
Text("上一班 \(previous)")
Text("下一班 \(next)")
.foregroundColor(.red)
}
}
}
}
}
之后用主体拼成整个界面。
套一个 ScrollView,就能直接获得数码表管转动震动等等一系列特性了。
struct BusView: View {
@State private var busViewModel: BusViewModel = BusViewModel()
var body: some View {
ScrollView {
VStack {
VStack(alignment: .leading) {
BusTableView(direction: "欣园", previous: busViewModel.XinYuanPrevious, next: busViewModel.XinYuanNext, iconName: "chevron.up")
BusTableView(direction: "科研楼", previous: busViewModel.KeYanLouPrevious, next: busViewModel.KeYanLouNext, iconName: "chevron.down")
// 此处有一行小灰字 ...
// 此处有两个按钮 ...
}
}
}
}
小灰字是根据 ViewModel 中的一个属性变化,所以用 Group 套一下,可以获得一个 if 声明的功能。
struct BusWorkView: View {
var isOnWeekDay: Bool
var body: some View {
Group {
if isOnWeekDay {
Text("今天校巴按工作日运行")
.fontWeight(.light)
} else {
Text("今天校巴按节假日运行")
.fontWeight(.light)
}
}
}
}
滚动到下面,有两个按钮。
这两个按钮直接用 Button 套 Text 完成。这里我本来套用的是 Label,还能搭配图标。很可惜目前 SwiftUI 的 Label 在 watchOS 7.0 Beta 里显示效果一塌糊涂(图标文字没法对齐),所以姑且用 Text 代替了。
VStack {
Button{
self.busViewModel.refreshBus()
} label: {
Text("更新时间")
}
Button {
self.busViewModel.swichModel()
} label:{
Text("变更运行模式")
}
}
这里用了还没有正式发布的 Swift 新语法:多重尾闭包 (Multiple trailing closure)。这个语法目前还没有中文译名。不过今年 9 月以后这个写法就会多起来了。
这次实现的非常简单,后续还可以加入多条线路选择、向服务器订阅校巴时刻表、假期安排等功能(其实也很简单)。希望南科大校巴多开几条线路,这样下一次博客的内容就有了……
© LICENSED UNDER CC BY-NC-SA 4.0