为 Apple Watch 写一个校巴时刻表工具

一转眼,SwiftUI 已经推出一年了。随着这个框架的流行,苹果软件开发的门槛也进一步降低,低到我也能踮踮脚够进去了。这个暑假,由于疫情的原因,原定去新加坡国立大学的暑期交流取消了。于是,计划已久的 SwiftUI 探寻就提前开始了。

目标简介

迫于在南科大,查询校巴的发车时间过于麻烦,因此写了一个 Apple Watch APP,用于查询南科大校园巴士最近的发车时间。作为 Swift 和 SwiftUI 的上手项目,代码逻辑非常简单,仅用于练习。项目完整代码已经上传到GitHub:https://github.com/whexy/SUSTechBus。

目前南科大的校巴一共有两条线:上山线(终点欣园)和下山线(终点科研楼)。在这个手表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(_ date: Date) {
        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月以后这个写法就会多起来了。

至此,我们就实现完了一个APP!完整代码在GitHub:https://github.com/whexy/SUSTechBus。这次实现的非常简单,后续还可以加入多条线路选择、向服务器订阅校巴时刻表、假期安排等功能(其实也很简单)。希望南科大校巴多开几条线路,这样下一次博客的内容就有了……