5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

iOSAdvent Calendar 2024

Day 21

【iOS】東京メトロの駅時刻表を Widgets に表示させてみる

Posted at

はじめに

東京メトロの時刻表は公共交通オープンデータ開発者サイトで提供されています。
(API を叩くために登録が必要です。)

他には飛行機やバスなどのデータも確認できます。
(全交通機関対応して欲しいなぁ🤔)

個人開発で乗車記録アプリを作っていて,
アプリ内に東京メトロ各線の各駅の時刻表を閲覧できる機能は作っているので
Widgets でリアルタイム(に近い形)で確認できたらいいなーということで挑戦してみます。

こんな感じで各路線の各駅の時刻表を閲覧できるようなアプリを作ってます。

24_q_mov_000

毎回アプリを起動せずとも Widgets で最寄駅の時刻表確認できたら嬉しいよな〜

今回作るもの

今回は東京メトロの駅の時刻表を取得して,Widgets で表示してみようと思います。

iOS アプリで閲覧したい駅を選択,時刻表取得APIをコールして
得られた時刻表データをローカルに保存して,
App Groups の機能を利用して Widgets 側に共有します。

時刻表は改正があるまで基本的に変更はないので,
毎回利用のたびに取得するのではなく,
任意のタイミングで取得するして保存する形で良いという判断です。

簡易的に Time schedule で 15 分おきくらいに更新できる形で
Widgets は各方面,現在の時刻から近いもの 3件をピックアップして表示させます。

駅のホームにある電子掲示板チックにしてみました。

開発環境

  • Xcode 16
  • iOS 16 以上
  • SwiftUI

サンプルアプリ

今回のサンプルアプリは GitHub にリポジトリを公開しております。
必要であればご覧ください。

API について

先述の通り,東京メトロの時刻表取得のためには公共交通オープンデータセンター開発者サイトに登録してアクセストークンを取得する必要があります。

アクセストークンは,外部から見られないように swift ファイルに定義しますが今回はその性質上ダミー文字列にします。(動作確認したい場合は適宜取得したものに書き換えてください)

東京メトロが提供している情報として例として下記があります。

  • 運賃情報
  • 駅情報
  • 路線系統情報
  • 駅時刻表
  • 運行情報
  • 列車時刻表
  • 乗降者数情報

今回は 駅時刻表API を叩きます。
下記を見てもわかるとおり,クエリ(とレスポンス)にちょっと癖があります。

駅時刻表APIのURL例
https://api.odpt.org/api/v4/odpt:StationTimetable?odpt:operator=odpt.Operator:TokyoMetro&acl:consumerKey={ここに入力}

時刻表を取得したい駅を選択する画面実装

選択可能な駅のデータ準備

時刻表を選択したい駅を選択できるように画面を実装していきます。

今回はサンプルとして,銀座線の銀座駅と日本橋駅を選択できるようにします。

enum で各駅をケースとして最低限の駅情報を管理することにします。
name は駅名,icon は駅アイコン画像になります。
identifier は後で時刻表取得API で用います。

SelectableStations.swift
enum SelectableStations: String, Codable, CaseIterable {
    case ginza = "odpt.Station:TokyoMetro.Ginza.Ginza"
    case nihombashi = "odpt.Station:TokyoMetro.Ginza.Nihombashi"

    var name: String {
        switch self {
        case .ginza:
            "銀座"

        case .nihombashi:
            "日本橋"
        }
    }

    var icon: Image {
        switch self {
        case .ginza:
            Image("G09")

        case .nihombashi:
            Image("G11")
        }
    }

    var identifier: String {
        switch self {
        case .ginza:
            "TokyoMetro.Ginza.Ginza"

        case .nihombashi:
            "TokyoMetro.Ginza.Nihombashi"
        }
    }
}

時刻表を取得したい駅を選択する画面実装

画面実装は下記のコードの通りです。
駅の選択は Menu を使って実現しました。
駅が選択されたら時刻表取得ボタンが有効になります。

ContentView.swift
struct ContentView: View {
    @State private var selectedStation: SelectableStations?

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 64.0) {
                    stationSelection()

                    timeTableGetButton()
                }
                .padding(.vertical, 64.0)
                .padding(.horizontal, 32.0)
            }
            .navigationTitle("時刻表取得")
            .navigationBarTitleDisplayMode(.inline)
        }
    }
}

private extension ContentView {
    func stationSelection() -> some View {
        Menu {
            ForEach(SelectableStations.allCases, id: \.self) { station in
                Button {
                    selectedStation = station
                } label: {
                    Label {
                        Text(station.name)
                    } icon: {
                        station.icon
                    }
                }
            }

        } label: {
            if let selectedStation = selectedStation {
                HStack {
                    selectedStation.icon
                        .resizable()
                        .scaledToFit()
                        .frame(width: 36.0, height: 36.0)

                    Text(selectedStation.name)
                        .font(.title2)
                        .bold()
                }
            } else {
                Text("時刻表を取得したい駅を選択")
                    .foregroundStyle(.blue)
            }
        }
        .tint(.black)
    }

    func timeTableGetButton() -> some View {
        Button {
            // TODO: 時刻表取得APIコール
        } label: {
            Text("時刻表取得")
                .font(.headline)
                .foregroundStyle(.white)
                .frame(maxWidth: .infinity)
                .frame(height: 44.0)
                .background(selectedStation == nil ? .gray.opacity(0.4) : .orange)
                .cornerRadius(8.0)
        }
        .disabled(selectedStation == nil)
    }
}

実行したら下記の GIF のようになります。

24_q_mov_001

駅選択後に「時刻表取得」ボタンをタップで
駅時刻表取得APIをコールしてデータを取得する形にしようと思います。

API 周りの実装

エンドポイントとクエリ

東京メトロ銀座線銀座駅の時刻表取得 API のエンドポイント・クエリを設定した URL は下記のようになります。(詳しくはAPI仕様の方をご覧ください。)

https://api.odpt.org/api/v4/odpt:StationTimetable?odpt:operator=odpt.Operator:TokyoMetro&odpt:station=odpt.Station:TokyoMetro.Ginza.Ginza&acl:consumerKey={取得したアクセストークン}

内容 詳細
odpt:StationTimetable 駅時刻表取得APIエンドポイント
odpt:operator 事業者を表すID
odpt:station 駅を表すID
acl:consumerKey アクセストークン

駅を表すID 部分に各駅の情報に代えて,API を叩く感じです。
先の駅情報に付与した identifier がそれにあたります。

実際に取れるレスポンス例は下記の通りです。
時刻表部分は量が多いので最初の 1件だけにしています。

Response例
[
  {
    "@id": "urn:ucode:_00001C00000000000001000003100FCD",
    "@type": "odpt:StationTimetable",
    "dc:date": "2023-04-29T09:00:00+09:00",
    "@context": "http://vocab.odpt.org/context_odpt_StationTimetable.jsonld",
    "dct:issued": "2023-04-29",
    "owl:sameAs": "odpt.StationTimetable:TokyoMetro.Ginza.Ginza.TokyoMetro.Asakusa.SaturdayHoliday",
    "odpt:railway": "odpt.Railway:TokyoMetro.Ginza",
    "odpt:station": "odpt.Station:TokyoMetro.Ginza.Ginza",
    "odpt:calendar": "odpt.Calendar:SaturdayHoliday",
    "odpt:operator": "odpt.Operator:TokyoMetro",
    "odpt:railDirection": "odpt.RailDirection:TokyoMetro.Asakusa",
    "odpt:stationTimetableObject": [
      {
        "odpt:train": "odpt.Train:TokyoMetro.Ginza.B535",
        "odpt:trainType": "odpt.TrainType:TokyoMetro.Local",
        "odpt:trainNumber": "B535",
        "odpt:departureTime": "05:17",
        "odpt:destinationStation": [
          "odpt.Station:TokyoMetro.Ginza.Asakusa"
        ]
      },
      ... // 各時刻のデータが続く
    ]
  },
  {
    "@id": "urn:ucode:_00001C00000000000001000003100FCB",
    "@type": "odpt:StationTimetable",
    "dc:date": "2023-04-29T09:00:00+09:00",
    "@context": "http://vocab.odpt.org/context_odpt_StationTimetable.jsonld",
    "dct:issued": "2023-04-29",
    "owl:sameAs": "odpt.StationTimetable:TokyoMetro.Ginza.Ginza.TokyoMetro.Asakusa.Weekday",
    "odpt:railway": "odpt.Railway:TokyoMetro.Ginza",
    "odpt:station": "odpt.Station:TokyoMetro.Ginza.Ginza",
    "odpt:calendar": "odpt.Calendar:Weekday",
    "odpt:operator": "odpt.Operator:TokyoMetro",
    "odpt:railDirection": "odpt.RailDirection:TokyoMetro.Asakusa",
    "odpt:stationTimetableObject": [
      {
        "odpt:train": "odpt.Train:TokyoMetro.Ginza.B549",
        "odpt:trainType": "odpt.TrainType:TokyoMetro.Local",
        "odpt:trainNumber": "B549",
        "odpt:departureTime": "05:17",
        "odpt:destinationStation": [
          "odpt.Station:TokyoMetro.Ginza.Asakusa"
        ]
      },
      ... // 各時刻のデータが続く
    ]
  },
  {
    "@id": "urn:ucode:_00001C00000000000001000003100FCA",
    "@type": "odpt:StationTimetable",
    "dc:date": "2023-04-29T09:00:00+09:00",
    "@context": "http://vocab.odpt.org/context_odpt_StationTimetable.jsonld",
    "dct:issued": "2023-04-29",
    "owl:sameAs": "odpt.StationTimetable:TokyoMetro.Ginza.Ginza.TokyoMetro.Shibuya.SaturdayHoliday",
    "odpt:railway": "odpt.Railway:TokyoMetro.Ginza",
    "odpt:station": "odpt.Station:TokyoMetro.Ginza.Ginza",
    "odpt:calendar": "odpt.Calendar:SaturdayHoliday",
    "odpt:operator": "odpt.Operator:TokyoMetro",
    "odpt:railDirection": "odpt.RailDirection:TokyoMetro.Shibuya",
    "odpt:stationTimetableObject": [
      {
        "odpt:train": "odpt.Train:TokyoMetro.Ginza.A501",
        "odpt:trainType": "odpt.TrainType:TokyoMetro.Local",
        "odpt:trainNumber": "A501",
        "odpt:departureTime": "05:19",
        "odpt:destinationStation": [
          "odpt.Station:TokyoMetro.Ginza.Shibuya"
        ]
      },
      ... // 各時刻のデータが続く
    ]
  },
  {
    "@id": "urn:ucode:_00001C00000000000001000003100FC8",
    "@type": "odpt:StationTimetable",
    "dc:date": "2023-04-29T09:00:00+09:00",
    "@context": "http://vocab.odpt.org/context_odpt_StationTimetable.jsonld",
    "dct:issued": "2023-04-29",
    "owl:sameAs": "odpt.StationTimetable:TokyoMetro.Ginza.Ginza.TokyoMetro.Shibuya.Weekday",
    "odpt:railway": "odpt.Railway:TokyoMetro.Ginza",
    "odpt:station": "odpt.Station:TokyoMetro.Ginza.Ginza",
    "odpt:calendar": "odpt.Calendar:Weekday",
    "odpt:operator": "odpt.Operator:TokyoMetro",
    "odpt:railDirection": "odpt.RailDirection:TokyoMetro.Shibuya",
    "odpt:stationTimetableObject": [
      {
        "odpt:train": "odpt.Train:TokyoMetro.Ginza.A501",
        "odpt:trainType": "odpt.TrainType:TokyoMetro.Local",
        "odpt:trainNumber": "A501",
        "odpt:departureTime": "05:18",
        "odpt:destinationStation": [
          "odpt.Station:TokyoMetro.Ginza.Shibuya"
        ]
      },
      ... // 各時刻のデータが続く
    ]
  }
]

ざっくり言うと,大きなデータの塊が
方面情報と平日or休日の組み合わせで
下記の通り計4つでそれぞれに時刻表が配列で取得できる感じです。

  • 浅草方面・土休日
  • 浅草方面・平日
  • 渋谷方面・土休日
  • 渋谷方面・平日

レスポンスを受けるモデル実装

取得したデータを受けるモデルを実装します。

/// 時刻表API全体のレスポンスを受けるモデル
struct TimeTable: Codable {
    var station: SelectableStations
    var railDirection: RailDirection
    var dayType: DayType
    var timeTable: [TimeInfo]
    
    enum CodingKeys: String, CodingKey {
        case station = "odpt:station"
        case railDirection = "odpt:railDirection"
        case dayType = "odpt:calendar"
        case timeTable = "odpt:stationTimetableObject"
    }
}

/// 方面情報(銀座線は渋谷方面or浅草方面)
enum RailDirection: String, Codable {
    case shibuya = "odpt.RailDirection:TokyoMetro.Shibuya"
    case asakusa = "odpt.RailDirection:TokyoMetro.Asakusa"

    var direction: String {
        switch self {
        case .shibuya:
            "渋谷方面"

        case .asakusa:
            "浅草方面"
        }
    }
}

/// 日付タイプ(平日or休日)
enum DayType: String, Codable, CaseIterable {
    case weekdays = "odpt.Calendar:Weekday"
    case holidays = "odpt.Calendar:SaturdayHoliday"
    
    var name: String {
        switch self {
        case .weekdays:
            "平日"

        case .holidays:
            "土休日"
        }
    }
}

/// 時刻表の各時刻の情報
struct TimeInfo: Codable, Hashable {
    /// 発車時刻
    var departureTime: String
    /// 列車種別
    var trainType: TrainType
    /// 運行番号
    var trainNumber: String
    /// 終点(どこ行きか)
    var destinationStation: [DestinationStation]
    /// 始発か
    var isOrigin: Bool?
    /// 最終電車か
    var isLast: Bool?
    
    enum CodingKeys: String, CodingKey {
        case departureTime = "odpt:departureTime"
        case trainType = "odpt:trainType"
        case trainNumber = "odpt:trainNumber"
        case destinationStation = "odpt:destinationStation"
        case isOrigin = "odpt:isOrigin"
        case isLast = "odpt:isLast"
    }
}

/// 列車種別(銀座線は各駅停車のみ)
enum TrainType: String, Codable, CaseIterable, Identifiable {
    case local = "odpt.TrainType:TokyoMetro.Local"

    var name: String {
        switch self {
        case .local:
            "各駅停車"
        }
    }
}

/// どこ行きかの終点情報(複数路線で該当駅がある場合があるのでケースを工夫)
enum DestinationStation: String, Codable {
    case shibuya = "odpt.Station:TokyoMetro.Ginza.Shibuya"
    case ueno = "odpt.Station:TokyoMetro.Ginza.Ueno"
    case asakusa = "odpt.Station:TokyoMetro.Ginza.Asakusa"

    var destination: String {
        switch self {
        case .shibuya:
            "渋谷"

        case .ueno:
            "上野"

        case .asakusa:
            "浅草"
        }
    }

    var icon: Image {
        switch self {
        case .shibuya:
            Image("G01")

        case .ueno:
            Image("G16")

        case .asakusa:
            Image("G19")
        }
    }
}

結構複雑でいっぱい定義しています。。。

実際の開発だとキー名もアプリで使いやすいように
サーバーチームと話したりしますが,
オープンソースなので必要なデータを取れるようにするために CodingKeys が忙しいです。

業務だと OpenAPI Generator とかを使って自動生成したりしますが,
個人開発でレスポンスが複雑でモデル生成が難しいようであれば,
下記のようなツールに頼って楽するのもありかなと思います。
結果を少し整形して使うみたいな感じです。

時刻表API疎通の実装

ここは自由にライブラリ選定してもいいですが,
今回は使わずに URLSession 使って書いてみます。

コールする API の URL を作る際に
選択された駅の「駅を表すID」をクエリを加えて生成する関数を別途実装しています。

他は実装として特別なことはしていないと思います。

TimeTableAPI.swift
final class TimeTableAPI {
    /// 駅時刻表APIをコールして時刻表データ取得
    /// - Parameter targetStation: 時刻表を取得したい駅
    /// - Returns: 取得できた時刻表データ
    func fetchTimeTable(targetStation: SelectableStations) async throws -> [TimeTable] {
        guard let apiUrl = generateAPIURL(targetStation: targetStation) else {
            throw APIError.apiURL
        }

        let (data, response) = try await URLSession.shared.data(from: apiUrl)

        guard let httpResponse = response as? HTTPURLResponse else {
            throw APIError.response
        }

        switch httpResponse.statusCode {
        case 200:
            do {
                let timeTableData = try JSONDecoder().decode([TimeTable].self, from: data)
                return timeTableData

            } catch {
                throw APIError.jsonDecode
            }

        default:
            throw APIError.statusCode(statusCode: httpResponse.statusCode.description)
        }
    }
}

private extension TimeTableAPI {
    /// 駅時刻表取得APIを叩くための URL を生成
    /// - Parameter targetStation: 時刻表を取得したい駅
    /// - Returns: 駅時刻表取得API を叩くための URL
    func generateAPIURL(targetStation: SelectableStations) -> URL? {
        let apiURLString = MetroTimeTableAPI.endpoint + APIQueries.main + targetStation.identifier + APIQueries.consumeKey
        return URL(string: apiURLString)
    }
}

struct MetroTimeTableAPI {
    static let endpoint = "https://api.odpt.org/api/v4/odpt:StationTimetable"
}

struct APIQueries {
    static let main = "?odpt:operator=odpt.Operator:TokyoMetro&odpt:station=odpt.Station:"
    static let consumeKey = "&acl:consumerKey=[取得したアクセストークン]"
}

エラー定義は下記の通りです。
(今後の実装を見越してケースは増やしています。)

APIError.swift
enum APIError: Error {
    case network
    case apiURL
    case appGroups
    case response
    case noData
    case jsonEncode
    case jsonDecode
    case statusCode(statusCode: String)
}

extension APIError: LocalizedError {
    var errorDescription: String? {
        switch self {
        case .network:
            return "ネットワークエラーです。通信状態を確認してください。"

        case .apiURL:
            return "時刻表APIのURL生成時にエラーが発生しました。"

        case .appGroups:
            return "該当のコンテナIDがありません"

        case .response:
            return "内部エラーが発生しました。(response)"

        case .noData:
            return "内部エラーが発生しました。(data)"

        case .jsonEncode:
            return "内部エラーが発生しました。(パースエラー)"

        case .jsonDecode:
            return "内部エラーが発生しました。(パースエラー)"

        case .statusCode(let statusCode):
            return "内部エラーが発生しました。(\(statusCode))"
        }
    }
}

駅時刻表取得API をコールしてみる

先ほど実装した API 疎通のコードを使って
「時刻表取得」ボタンをタップした際に駅時刻表のデータを取得してみます。

今回は ViewModel に取得のコードを書いてみます。

ContentViewModel
@MainActor
final class ContentViewModel: ObservableObject {
    @Published private(set) var timeTable: [TimeTable] = []

    private let timeTableAPI = TimeTableAPI()

    // 「時刻表取得」ボタンが押された際に呼ばれる関数
    func didTapTimeTableFetch(targetStation: SelectableStations) {
        fetchTimeTable(targetStation: targetStation)
    }
}

private extension ContentViewModel {
    /// 該当の駅時刻表を取得
    /// - Parameter targetStation: 時刻表を取得したい駅
    func fetchTimeTable(targetStation: SelectableStations) {
        Task {
            do {
                timeTable = try await timeTableAPI.fetchTimeTable(targetStation: targetStation)
                print(timeTable)

            } catch {
                print(error)
            }
        }
    }
}

次に View 側で VIewModel の初期化とボタンタップ時に関数を呼ぶ実装は下記の通りです。

ContentView.swift
@StateObject private var viewModel = ContentViewModel() // 定義追加

func timeTableGetButton() -> some View {
    Button {
        // 時刻表取得APIコール
        viewModel.didTapTimeTableFetch(targetStation: selectedStation!)
    } label: {
        Text("時刻表取得")
            ... // 省略
    }
}

実行して,エラーにならなければちゃんとデータ取得できてパースも成功しているはずです。
標準出力とかで結果を見てみると良いです。

取得したデータを App Groups を利用して共有できるように

Enable communication and data sharing between multiple installed apps created by the same developer.

App Groups は同じ開発者のアプリでデータをシェアできる機能で,
使い慣れた User Defaults で共有コンテナを介して共通のデータを扱えます。
結構前からあった機能で App Clips や Widgets などにも応用効いた形ですね。

コンテナ ID を設定して取得したデータを保存

詳しくは先のリンクに書いてあるので省きますが,
今回はコンテナID を下記の画像のように設定しました。

002

アプリ側のターゲットでチェックも入れます。

003

取得したデータはモデルに格納したので,User Defaults には Data 型で保存します。
(Data 型で取ったのだからそのまま扱った方が良かったかも(モヤモヤ))

保存したデータ確認用の関数も追加しておきます。

TimeTableDataManager.swift
final class TimeTableDataManager {
    private let userDefaults = UserDefaults(suiteName: "group.com.ASTK.TimeTableSample")
    private let timeTableDataKey = "timeTableData"

    /// App Groups で時刻表のデータを共有できるように保存する
    /// - Parameters:
    ///   - timeTable: 時刻表データ
    ///   - completion: 完了処理用のクロージャ
    func storeTimeTableData(timeTable: [TimeTable], completion: (() -> Void)? = nil) throws {
        do {
            let data = try JSONEncoder().encode(timeTable)

            if let userDefaults = userDefaults {
                userDefaults.set(data, forKey: timeTableDataKey)
                completion?()
            }

        } catch {
            throw APIError.jsonEncode
        }
    }

    /// App Groups で共有している時刻表のデータを取得
    /// - Parameters:
    ///   - timeTable: 時刻表データ
    ///   - completion: 完了処理用のクロージャ
    func getTimeTableData(completion: (() -> Void)? = nil) throws -> [TimeTable] {
        do {
            if let userDefaults = userDefaults {
                if let data = userDefaults.object(forKey: timeTableDataKey) as? Data {
                    return try JSONDecoder().decode([TimeTable].self, from: data)
                } else {
                    throw APIError.noData
                }
            } else {
                throw APIError.appGroups
            }
        } catch {
            throw APIError.jsonDecode
        }
    }
}

ViewModel でデータ取得後に保存できるようにコードを追加します。

ContentViewModel.swift
@MainActor
final class ContentViewModel: ObservableObject {
    @Published private(set) var timeTable: [TimeTable] = []
+   @Published var storeCompleted = false

    private let timeTableAPI = TimeTableAPI()
+   private let timeTableManager = TimeTableDataManager()

    func didTapTimeTableFetch(targetStation: SelectableStations) {
        fetchTimeTable(targetStation: targetStation)
    }
}

private extension ContentViewModel {
    /// 該当の駅時刻表を取得
    /// - Parameter targetStation: 時刻表を取得したい駅
    func fetchTimeTable(targetStation: SelectableStations) {
        Task {
            do {
                timeTable = try await timeTableAPI.fetchTimeTable(targetStation: targetStation)
                print(timeTable)

		        // 取得したデータを保存して完了後にアラート表示
+               try timeTableManager.storeTimeTableData(timeTable: timeTable) {
+                   self.storeCompleted = true
+               }
+
+               // 保存できているかの確認コード
+               // let storedData = try timeTableManager.getTimeTableData()
+               // print(storedData)

            } catch {
                print(error)
            }
        }
    }
}

あとは View 側で完了クロージャを受けてアラートを出すような実装をしておきます。

ContentView.swift
struct ContentView: View {
    @StateObject private var viewModel = ContentViewModel()
    @State private var selectedStation: SelectableStations?

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 64.0) {
                    stationSelection()

                    timeTableGetButton()
                }
                .padding(.vertical, 64.0)
                .padding(.horizontal, 32.0)
            }
            .navigationTitle("時刻表取得")
            .navigationBarTitleDisplayMode(.inline)
        }
+       .alert( // 追加
+           "時刻表取得・保存が完了しました。",
+           isPresented: $viewModel.storeCompleted
+       ) { }
    }
}

iOS アプリ側の実装は完了です。
実行すると下記のようになります。

24_q_mov_002

Widget の実装

最後に Widgets の実装をします。

Widget Extension 追加

公式ドキュメントもあるので参考に実装します。

File -> New -> TargetsWidget Extension を選択。

004

Widgets の名前を入力して,チェックは今回は全部外します。

005

Finish ボタンタップで出てくる画面で 「Activate」ボタンタップしてスキームを有効に。

006

Widgets のスキームを選択して実行するとシミュレータで Widgets が表示されています。

007 008

Widgets での時刻表データの取り扱い

前準備として,
追加した Widget Extension のターゲットでも使うファイルの
Target Membership を設定します。(チェック入れるだけ)
モデルとデータを扱うファイル,駅アイコンのアセット等が今回の該当です。

009

次に,App Groups を利用して保存していた時刻表データを取得できるように
追加した Widget Extension のターゲットでもコンテナIDをチェック入れます。

010

TimeEntry をデフォルトのものから TimeTableEntry として書き換えます。
絵文字ではなく時刻表データを持つように書き換えました。

TimeTableSampleWidgets.swift
struct TimeTableEntry: TimelineEntry {
     let date: Date     
     let timeTable: [TimeTable]
}

同じく Provider の必須関数の該当箇所も書き換えます。
一旦ビルド通すために時刻表データは空配列にして,
後でサンプルデータを作ります(時間あったら)。

TimeTableSampleWidgets.swift
struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> TimeTableEntry {
        TimeTableEntry(date: Date(), timeTable: [])
    }

    func getSnapshot(in context: Context, completion: @escaping (TimeTableEntry) -> ()) {
        let entry = TimeTableEntry(date: Date(), timeTable: [])
        completion(entry)
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [TimeTableEntry] = []

        // Generate a timeline consisting of five entries an hour apart, starting from the current date.
        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = TimeTableEntry(date: entryDate, timeTable: [])
            entries.append(entry)
        }
    }
}

現在時間から15分ごとに4回(1時間のタイムラインに)
各方面の最新の時刻表 3件ずつに絞ったデータを作り
タイムライン用の配列に格納します。

TimeTableSampleWidgets.swift
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
    var entries: [TimeTableEntry] = []

    // Generate a timeline consisting of five entries an hour apart, starting from the current date.
    let currentDate = Date()

    // FIXME: 0時過ぎの時刻表が取れない&平日・土休日がズレる&データの作りが急造

    let dateFormmater = DateFormatter()
    dateFormmater.dateFormat = "HH:mm"
    dateFormmater.locale = Locale(identifier: "en_US_POSIX")

    // 今日が平日か土休日か
    let dayType: DayType = Calendar.current.isDateInWeekend(currentDate) ? .holidays : .weekdays

    do {
        // 平日か土休日かのデータをフィルタ
        let targetData = try TimeTableDataManager().getTimeTableData()
            .filter( { $0.dayType == dayType })

        // 15分ごとにデータを作ってタイムライン用の配列に格納
        for offset in 0 ..< 4 {
            let entryDate = Calendar.current.date(byAdding: .minute, value: 15 * offset, to: currentDate)!
            // 時刻表データでentryDateに近いものを最大3つピックアップ
            var filteredTimeTable: [TimeTable] = []
            for data in targetData {
                let timeTable = data.timeTable.filter( { dateFormmater.date(from: $0.departureTime)! > dateFormmater.date(from: dateFormmater.string(from: entryDate))! }).prefix(3)
                filteredTimeTable.append(TimeTable(station: data.station, railDirection: data.railDirection, dayType: data.dayType, timeTable: Array(timeTable)))
            }
            let entry = TimeTableEntry(date: entryDate, timeTable: filteredTimeTable)
            entries.append(entry)
        }

        // entries分の表示が終わったら再度タイムライン用のデータを作る
        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)

    } catch {
        // TODO: エラーハンドリング
    }
}

TimelineReloadPolicy.atEnd
15分 * 4 = 1時間ごとにタイムライン用のデータを作って更新されます。

時刻の比較がぱっといい方法考えつかずで,
上記コードだと該当時間から3つピックアップする際に
時刻表のデータ形式が HH:mm なので
日付が変わった際に色々問題が起きます・・・

23時後半で0時台のものがピックアップされなかったり,
平日から休日に変わったけど 0時台の時刻表は平日のものだよねとか,です。
今後の課題としてタイムライン用のデータの作り方は考えます。

これで表示用のデータの準備ができたので
Widgets の UI実装に入ります。

Widgets の UI 実装

今回の WidgetFamily は表示内容を考慮して .systemMedium だけにします。
ついでに iOS 17 以降で描画領域の周りにマージンが入るので全領域に広げるための設定を入れておきます。

TimeTableSampleWidgets.swift
struct TimeTableSampleWidgets: Widget {
    let kind: String = "TimeTableSampleWidgets"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            if #available(iOS 17.0, *) {
                TimeTableSampleWidgetsEntryView(entry: entry)
                    .containerBackground(.fill.tertiary, for: .widget)
            } else {
                TimeTableSampleWidgetsEntryView(entry: entry)
                    .padding()
                    .background()
            }
        }
+       .contentMarginsDisabled() // iOS 17以降でマージンが周りに入るので描画領域を全体に
+       .supportedFamilies([.systemMedium]) // サポートするWidget Family
        .configurationDisplayName("Time Table")
        .description("This is an example widget.")
    }
}

TimeTableSampleWidgetsEntryView で Widgets の UI 実装をしていきます。

TimeTableSampleWidgets.swift
struct TimeTableSampleWidgetsEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        VStack(spacing: .zero) {
            HStack(spacing: 8.0) {
                entry.timeTable.first?.station.icon
                    .frame(width: 36.0, height: 36.0)

                Text(entry.timeTable.first?.station.name ?? "--")
                    .font(.headline)
                    .foregroundStyle(.white)
            }
            .padding(.bottom, 4.0)

            HStack(spacing: .zero) {
                ForEach(entry.timeTable, id: \.railDirection) { data in
                    VStack(alignment: .leading, spacing: .zero) {
                        Text(data.railDirection.direction)
                            .font(.footnote)
                            .bold()
                            .foregroundStyle(.white)
                            .padding(.leading, 8.0)
                            .padding(.bottom, 4.0)

                        ForEach(data.timeTable, id: \.trainNumber) { timeInfo in
                            HStack(spacing: 4.0) {
                                Text(timeInfo.departureTime)
                                    .font(.caption)
                                    .bold()
                                    .monospacedDigit()
                                    .foregroundStyle(.white)
                                    .padding(.leading, 2.0)

                                Text(timeInfo.trainType.name)
                                    .frame(width: 20.0, height: 20.0)
                                    .font(.caption2)
                                    .lineLimit(2)
                                    .minimumScaleFactor(0.1)
                                    .foregroundStyle(.white)

                                timeInfo.destinationStation.first?.icon
                                    .resizable()
                                    .scaledToFit()
                                    .frame(width: 20.0, height: 20.0)

                                Text(timeInfo.destinationStation.first?.destination ?? "--")
                                    .font(.caption)
                                    .bold()
                                    .foregroundStyle(.white)
                                    .frame(maxWidth: .infinity, alignment: .leading)
                                    .minimumScaleFactor(0.1)
                            }
                        }
                        .padding(.horizontal, 4.0)
                        .padding(.vertical, 2.0)
                        .background(.black)
                    }
                }
            }

            Spacer()
        }
        .padding(.all, 16.0)
        .frame(maxWidth: .infinity)
        .background(Color("navyBlue"))
        .overlay(alignment: .topTrailing) {
            Text(entry.timeTable.first?.dayType.name ?? "--")
                .font(.caption2)
                .foregroundStyle(.white)
                .padding(.all, 4.0)
                .overlay(
                    RoundedRectangle(cornerRadius: 4.0)
                        .stroke(.white, lineWidth: 1.0)
                )
                .padding(.all, 20.0)
        }
    }
}

iOS アプリ側で駅の時刻表取得・保存後に Widgets を確認すると・・・
両方面の時刻表データが3つ表示されてます🎉

011

15分ごとに更新もされていそうです。
ただ,銀座線の発着回数が多く 15分だと少し物足りない気がします。
もう少し表示数増やした方がいいかもしれないです。

次のステップ

今回は,決まった駅の時刻表を取得しました。
なので路線の駅リストから選択して時刻表を取得できるように工夫する必要があります。

また,今回は UserDefaults のキーをひとつにしたため,
複数駅の Widgets 表示ができていません。
自宅と職場の最寄り駅といった複数の駅の時刻表を閲覧可能にする等の
ユースケースを考慮して設定可能な Widgets の実装が必要になりそうです。
また機会があれば記事書こうと思います。

おわりに

今回は,東京メトロの駅時刻表を Widgets に表示させるサンプルアプリを作ってみました。
App Groups や簡単な Widgets の実装で少し知識がつきました。
もう少し実装を見直して,機能追加としてリリースできたらいいなーと思います。

Qiita の Advent Calendar は1年の締めくくりとして毎年参加していて,
業務で触れない技術や個人開発用のサンプルアプリを作って
その模様を記事にするといった感じで取り組んでいます。
勤怠アプリのように実際にアプリリリースできた内容もあります。

もう1年経つのかぁ〜と毎年感じていて,
今年はよくやったんだろうか?と思い返しています。
業務の方は良いとして個人開発の方はイマイチだったかもな🤔

乱文乱筆でしたが,
ご覧いただきありがとうございました!
良いクリスマス・良いお年をお迎えください。

参考

5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?