MoTLab -Mobility Technologies Engineering Blog-MoTLab -Mobility Technologies Engineering Blog-

「GO」のiOSアプリにWidgetを追加してみた話

ChallengeWeekiOS
June 15, 2022

タクシーアプリ「GO」の iOS アプリを開発している古屋です。

今回は「GO」にあったら便利になりそうなWidgetのプロトタイプを作ってみたので、その紹介をしたいと思います。


これは MoT Engineer Challenge Week 2022 Spring の記事です。

はじめに

ユーザーアプリチームでは定期的に「GO」にこういう機能があったらいいね、最新OSで使えるこの機能入れてみたいねという話をします。

An image from Notion

OS

ただ、今までは隙間時間で各自が調べる程度で動くものを作るまでは中々できていませんでした。

そんな中、FY2021 下期から Engineer Challenge Week で半期に2週間案件以外に時間を割くことができるようになったので、まずはWidgetから作ってみることにしました。

※WidgetはiOS 14以降、WidgetKitを使うことでホーム画面に表示できるようになった機能です

作るWidgetはこの2つにしました。

  • 予約情報Widget
  • お気に入り地点Widget

予約情報Widgetを作る

表示内容

  • 直近の予約情報

挙動

  • 直近の予約時間が過ぎたら次の予約情報を表示する

Widgetで表示するデータの取得

Widgetでデータを取得する場合のパターンは下記のどちらかですが、

  • APIからデータを取得
  • App Groups経由でローカルからデータを取得

予約は「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の更新を行います。

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 も適切に設定しておく必要があります。

Widgetの表示

対応する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("直近の予約情報を確認できます")
    }
}

完成

An image from Notion

アプリを開かずに予約時間と乗車場所を確認できるようになりました。

お気に入り地点Widgetを作る

表示内容

  • お気に入り地点

挙動

  • お気に入り地点をタップすると、その地点がセットされた状態でアプリが開く

データの取得部分に関しては予約情報Widgetと同様の理由で App Groups経由でローカルからデータを取得にしています。

Widgetの表示

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の内の要素ごとに遷移先を変えることができます。

完成

ホーム画面からワンタップで配車の直前まで行けるようになりました。

毎回決まった場所から呼ぶような人にはとても便利ですね。

複数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()
    }
}
An image from Notion

これで予約情報、お気に入り地点Widgetの完成です。

さいごに

普段触ることのない機能を触れるのはエンジニアとして非常に楽しかったです。

また実際に作ってみると、「やっぱりこれはあると便利だな」とか「あれも入れてみてもいいのでは?」のようなアイデアがいろいろ浮かんできます。

そして動くものがあるのでPdMに提案をする上でも非常に話がスムーズだと感じました。

次回の MoT Engineer Challenge Week はWWDC 2022後になるので、iOS 16で使えるようになった新機能のプロトタイプを何か作っていこうと思います。


We're Hiring!

📢
Mobility Technologies ではともに働くエンジニアを募集しています。

興味のある方は 採用ページ も見ていただけると嬉しいです。

Twitter @mot_techtalk のフォローもよろしくお願いします!