タクシーアプリ「GO」の iOS アプリを開発している古屋です。
今回は「GO」にあったら便利になりそうなWidgetのプロトタイプを作ってみたので、その紹介をしたいと思います。
これは MoT Engineer Challenge Week 2022 Spring の記事です。
ユーザーアプリチームでは定期的に「GO」にこういう機能があったらいいね、最新OSで使えるこの機能入れてみたいねという話をします。
ワーケーションで話した、入れてみたいOSの新しい機能
ただ、今までは隙間時間で各自が調べる程度で動くものを作るまでは中々できていませんでした。
そんな中、FY2021 下期から Engineer Challenge Week で半期に2週間案件以外に時間を割くことができるようになったので、まずはWidgetから作ってみることにしました。
※WidgetはiOS 14以降、WidgetKitを使うことでホーム画面に表示できるようになった機能です
作るWidgetはこの2つにしました。
表示内容
挙動
Widgetでデータを取得する場合のパターンは下記のどちらかですが、
予約は「GO」アプリ上からしかできないので App Groups経由でローカルからデータを取得 を今回は選択しました。
class ReservationStore {
// Widgetと共有するためにsuiteNameを設定する
let userDefaults = UserDefaults(suiteName: "group.xxx.xxx")
var requests = [ReservationRequest]()
func appendRequest(request: ReservationRequest) {
requests.append(request)
updateUserDefaults(requests: requests)
}
func removeRequests(id: ReservationID) {
requests = requests.filter { $0.id != id }
updateUserDefaults(requests: requests)
}
private func updateUserDefaults(requests: [ReservationRequest]) {
let encoder = JSONEncoder()
do {
let json = try encoder.encode(requests)
userDefaults?.set(json, forKey: "requests")
if #available(iOS 14, *) {
// アプリ側からWidgetのTimelineを更新する
WidgetCenter.shared.reloadTimelines(ofKind: "ReservationWidget")
}
} catch {}
}
}
本体アプリ側で予約の追加、削除があった時にUserDefaultsを更新し WidgetCenter.shared.reloadTimelines を呼び出すことで、アプリ側からWidgetの更新を行います。
struct ReservationProvider: IntentTimelineProvider {
// 本体側で設定しているものと同じsuiteNameを設定
let userDefaults = UserDefaults(suiteName: "group.xxx.xxx")
func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<ReservationEntry>) -> ()) {
var entries: [ReservationEntry] = []
if let json = userDefaults?.data(forKey: "requests") {
var reservations = [ReservationRequest]()
let decoder = JSONDecoder()
do {
// 時間が過ぎた予約は表示しない
reservations = try decoder.decode([ReservationRequest].self, from: json).filter { !$0.isExpired }
// 予約した時間になったら次の予約を表示するためにdateはひとつ前のものを使う
for i in 0..<reservations.count {
let date = i == 0 ? Date() : reservations[i-1].date
let list = Array(reservations[i..<reservations.count])
entries.append(ReservationEntry(date: date, reservations: list))
if i == reservations.count - 1 {
let lastDate = reservations[i].date
entries.append(ReservationEntry(date: lastDate, reservations: []))
}
}
} catch {}
} else {
entries.append(contentsOf: [.init(date: Date(), reservations: [])])
}
// 予約の追加、削除時は本体アプリ側で更新をかけるのでTimelineReloadPolicyは.neverにしておく
let timeline = Timeline(entries: entries, policy: .never)
completion(timeline)
}
}
struct ReservationEntry: TimelineEntry {
var date: Date
var reservations: [ReservationRequest]
}
予約の追加、削除時はアプリ本体からWidgetの更新が呼ばれるので、特に考慮は必要はないのですが、直近の予約した時間を過ぎた後に次の予約を表示するにはWidget側で更新をする必要があります。
予約の場合は表示を更新すべき時間がはっきりとわかっているので、予約の数だけ TimelineEntry を生成し date にはひとつ前の予約の時間をセットしておくことで、前の予約の時間が過ぎた後に次の予約情報を表示することができます。
TimelineEntry の間隔は少なくとも5分以上空けることが求められるため、更新頻度が高いアプリは注意が必要です。
またWidgetの更新頻度はWidgetKitによって、上限が設定されているので TimelineReloadPolicy も適切に設定しておく必要があります。
対応するWidgetFamilyごとにViewをSwiftUIで作るだけなので、とても簡単です。
struct ReservationEntryView : View {
@Environment(\.widgetFamily) var family: WidgetFamily
var entry: ReservationProvider.Entry
var body: some View {
switch family {
case .systemSmall: ReservationWidgetSmallView(entry: entry)
case .systemMedium: ReservationWidgetMediumView(entry: entry)
default: fatalError()
}
}
}
@main
struct ReservationWidget: Widget {
let kind: String = "ReservationWidget"
var body: some WidgetConfiguration {
IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: ReservationProvider()) { entry in
ReservationEntryView(entry: entry)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.white100)
}
// small,mediumだけ対応する
.supportedFamilies([.systemSmall, .systemMedium])
.configurationDisplayName("予約情報")
.description("直近の予約情報を確認できます")
}
}
アプリを開かずに予約時間と乗車場所を確認できるようになりました。
表示内容
挙動
データの取得部分に関しては予約情報Widgetと同様の理由で App Groups経由でローカルからデータを取得にしています。
Widgetは .systemSmall 以外のWidgetFamilyの場合、Widget内の各要素タップ時にそれぞれ遷移先を設定することができるので、今回は .systemMedium で作成しました。
.systemSmall の場合はWidgetそのもののタップ時の遷移先のみ指定できます。
@main
struct FavoriteWidget: Widget {
let kind: String = "FavoriteWidget"
var body: some WidgetConfiguration {
IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: FavoriteProvider()) { entry in
FavoriteWidgetEntryView(entry: entry)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.white100)
}
// systemSmallは各要素ごとにLinkを設定できないので設定しない
.supportedFamilies([.systemMedium])
.configurationDisplayName("お気に入り地点で配車")
.description("お気に入り地点で配車依頼をかけます")
}
}
struct FavoriteButtonView: View {
var place: FavoritePlace
var body: some View {
Link(destination: URL(string: "xxx://favorite?id=\(place.id)")!) {
Text(place.name)
.foregroundColor(Color.white100)
.frame(width: 120, height: 44)
.background(Color.main)
.cornerRadius(22)
}
}
}
struct FavoriteWidgetMediumView: View {
var entry: FavoriteProvider.Entry
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text("ここにタクシーを呼ぶ")
.foregroundColor(Color.black100)
ForEach(entry.places, id: \.id) { place in
FavoriteButtonView(place: place)
}
}
}
このようにLinkを設定することで、Widgetの内の要素ごとに遷移先を変えることができます。
ホーム画面からワンタップで配車の直前まで行けるようになりました。
毎回決まった場所から呼ぶような人にはとても便利ですね。
上記2つのWidgetを作成しましたが、多くのサンプルコードだとこのような感じで実装されていて、複数のWidgetに @main をつけることはできないので、一つのWidgetしか表示できません。
@main
struct HogeWidget: Widget {
var body: some WidgetConfiguration {
IntentConfiguration(kind: "hoge", intent: ConfigurationIntent.self, provider: HogeProvider()) { entry in
HogeEntryView(entry)
}
}
}
@main // @mainは複数使えない🙅♂️
struct FugaWidget: Widget {
複数表示したい場合は WidgetBundle プロトコルに準拠したものに @main をつけることで表示できます。
@main
struct GOWidgets: WidgetBundle {
@WidgetBundleBuilder
var body: some Widget {
ReservationWidget()
FavoriteWidget()
}
}
これで予約情報、お気に入り地点Widgetの完成です。
普段触ることのない機能を触れるのはエンジニアとして非常に楽しかったです。
また実際に作ってみると、「やっぱりこれはあると便利だな」とか「あれも入れてみてもいいのでは?」のようなアイデアがいろいろ浮かんできます。
そして動くものがあるのでPdMに提案をする上でも非常に話がスムーズだと感じました。
次回の MoT Engineer Challenge Week はWWDC 2022後になるので、iOS 16で使えるようになった新機能のプロトタイプを何か作っていこうと思います。
興味のある方は 採用ページ も見ていただけると嬉しいです。
Twitter @mot_techtalk のフォローもよろしくお願いします!