4
1

More than 1 year has passed since last update.

WidgetKitで天気予報アプリ作ってみた〜タイムライン作成編〜

Last updated at Posted at 2022-02-19

投稿の経緯

前回投稿したWidgetKitで天気予報アプリ作ってみた~Widget Extension追加編~の続編です。
今回はウィジェットで使うデータを作成するタイムラインを開発していきます。

前回の記事を見てない方は先に↓こちら↓を確認してください。

環境

Swift 5.5
Xcode 13.2.1

サンプルプロジェクト

GitHubにPushしています。気になる方はご覧ください。
https://github.com/ken-sasaki-222/WeatherWidget
QR_615642.png

AppGroupsの追加

UserDefaultsに保存したデータを「Widget Extension」と共有したいので「AppGroups」を追加します。

参考になる記事を添付しておきます。

「App Groups」の設定が完了したらUserDefaultsDataStoreを書き換えます。

UserDefaultsDataStore.swift
import Foundation

final class UserDefaultsDataStore {
    private let groupId = "group.com.ken.WeatherWidget"
    
    private enum DefaultsKey: String {
        case lat
        case lng
    }
    
    private var defaults: UserDefaults {
        guard let defaults = UserDefaults(suiteName: groupId) else {
            return UserDefaults.init()
        }
        return defaults
    }
    
    var lat: Double {
        get {
            defaults.double(forKey: DefaultsKey.lat.rawValue)
        }
        set(newValue) {
            defaults.set(newValue, forKey: DefaultsKey.lat.rawValue)
        }
    }
    
    var lng: Double {
        get {
            defaults.double(forKey: DefaultsKey.lng.rawValue)
        }
        set(newValue) {
            defaults.set(newValue, forKey: DefaultsKey.lng.rawValue)
        }
    }
}

suiteNameにApp GroupsのIDを渡すことでUserDefaultsの値を共有することができます。これでアプリ側で保存した緯度経度の情報を「Widget Extension」でも使えるようになりました。

続いて、緯度経度から地点名を取得するクラスを作成します。

緯度経度から地点名を取得

地点名はCLGeocoderを使って緯度経度から取得します。(逆ジオコーディング)

リクエストModel作成

ReverseGeocodeRequestModel.swift
import Foundation

struct ReverseGeocodeRequestModel {
    var lat: Double
    var lng: Double
}

UserDefaultsDataStoreから取得した緯度経度を格納するモデルです。

レスポンスModel作成

ReverseGeocodeResponceModel.swift
import Foundation

struct ReverseGeocodeResponceModel {
    var location: String
}

緯度経度から取得した地点名を格納するモデルです。

DataStore作成

ReverseGeocodeDataStore.swift
import CoreLocation

final class ReverseGeocodeDataStore {
    
    func fetchLocationFromLatLng(requestModel: ReverseGeocodeRequestModel) async throws -> ReverseGeocodeResponceModel {
        let location = CLLocation(latitude: requestModel.lat, longitude: requestModel.lng)
        
        return try await withCheckedThrowingContinuation { continuation in
            CLGeocoder().reverseGeocodeLocation(location) { placemarks, error in
                if let error = error {
                    print("Error fetch location from lat lng.")
                    print("Error message:", error)
                    print("Error locarized message:", error.localizedDescription)
                    continuation.resume(throwing: error)
                }
                
                if let locality = placemarks?.first?.locality {
                    let responce = ReverseGeocodeResponceModel(location: locality)
                    continuation.resume(returning: responce)
                } else {
                    continuation.resume(throwing: NSError(domain: "Error locality not found.", code: -2))
                }
            }
        }
    }
}

withCheckedThrowingContinuationを使ってasync/awaitで非同期処理を書いています。
実際に緯度経度から地点名が返ってくるかどうかの確認をしたいので、ユニットテストも書きました。

TestHelper.swift
import Foundation

class TestHelper {
    static let lat = 35.646543998983674
    static let lng = 139.62974883727196
}
ReverseGeocodeDataStoreTests.swift
import XCTest

class ReverseGeocodeDataStoreTests: XCTestCase {

    override func setUpWithError() throws {
        // Put setup code here. This method is called before the invocation of each test method in the class.
    }

    override func tearDownWithError() throws {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }

    func testFetchLocationFromLatLng() async throws {
        let dataStore = ReverseGeocodeDataStore()
        let requestModel = ReverseGeocodeRequestModel(lat: TestHelper.lat, lng: TestHelper.lng)
        let responce = try await dataStore.fetchLocationFromLatLng(requestModel: requestModel)
        
        print("Success test fetch location from lat lng.")
        print("responce:", responce)
        
        XCTAssertEqual("世田谷区", responce.location)
    }
}

テストが成功して正常に値を取得できているのであればDataStoreの開発は完了です。次はDataStoreへアクセスするRepositoryを作成します。

Repository作成

ReverseGeocodeRepositoryInterface.swift
import Foundation

protocol ReverseGeocodeRepositoryInterface {
    func fetchLocationFromLatLng(requestModel: ReverseGeocodeRequestModel) async throws -> ReverseGeocodeResponceModel
}
ReverseGeocodeRepository.swift
import Foundation

class ReverseGeocodeRepository: ReverseGeocodeRepositoryInterface {
    private let reverseGeocodeDataStore = ReverseGeocodeDataStore()
    
    func fetchLocationFromLatLng(requestModel: ReverseGeocodeRequestModel) async throws -> ReverseGeocodeResponceModel {
        do {
            let responce = try await reverseGeocodeDataStore.fetchLocationFromLatLng(requestModel: requestModel)
            return responce
        }
        catch {
            throw error
        }
    }
}

これでRepositoryを経由してDataStoreへアクセスし、緯度経度から地点名を取得することができます。

こちらのRepositoryは後ほど作成する「TimeLineProvider」から呼ばれます。
次はウィジェットのEntryModelを作成します。

EntryModel作成

ウィジェットのタイムラインに登録するデータを格納するEntryModelを作成します。
EntryModelはウィジェットで表示するデータをViewで扱いやすくする役割も担当します。ViewModel的な立場になりそうです。(たぶん)

Viewで必要なデータは以下の通り。

  • 現在時刻
  • 現在時刻から24時間分の天気情報
  • 現在の地点名
  • 天気アイコン名の配列
  • 時間テキストの配列
  • 気温の配列
  • 気圧の配列

これらをEntryModelで作成します。

EntryModel

MediumWidgetEntryModel.swift
import WidgetKit
import SwiftUI

struct MediumWidgetEntryModel: TimelineEntry {
    let date: Date
    var hourlyWeathers: [Hourly]
    var currentLocation: String?
    var weatherIcons: [String]
    var timePeriodTexts: [String]
    var temperatureTexts: [String]
    var hourlyPressures: [Double]
    
    init(currentDate: Date, hourlyWeathers: [Hourly], currentLocation: String?) {
        self.date = currentDate
        self.currentLocation = currentLocation
        
        self.hourlyWeathers = []
        self.weatherIcons = []
        self.timePeriodTexts = []
        self.temperatureTexts = []
        self.hourlyPressures = []
        
        for index in 0..<24 {
            self.hourlyWeathers.append(hourlyWeathers[index])
            self.hourlyPressures.append(hourlyWeathers[index].pressure)
            
            let timePeriodText = getTimePeriodText(hourlyWeather: hourlyWeathers[index])
            self.timePeriodTexts.append(timePeriodText)
            
            let weather = hourlyWeathers[index].weather[0]
            if let weatherIconName = getWeatherIconName(weather: weather) {
                self.weatherIcons.append(weatherIconName)
            }
            
            let temp = String(format: "%0.0f", hourlyWeathers[index].temp)
            self.temperatureTexts.append(temp)
        }
    }
    
    func getTimePeriodText(hourlyWeather: Hourly) -> String {
        let date = Date(timeIntervalSince1970: hourlyWeather.dt)
        let dateString = DateFormatHelper.shared.formatToHHmm(date: date)
        var timePeriodText: String
        
        if dateString == "00:00" {
            timePeriodText = "0"
        } else if dateString == "03:00" {
            timePeriodText = "3"
        } else if dateString == "06:00" {
            timePeriodText = "6"
        } else if dateString == "09:00" {
            timePeriodText = "9"
        } else if dateString == "12:00" {
            timePeriodText = "12"
        } else if dateString == "15:00" {
            timePeriodText = "15"
        } else if dateString == "18:00" {
            timePeriodText = "18"
        } else if dateString == "21:00" {
            timePeriodText = "21"
        } else {
            timePeriodText = "・"
        }
        
        return timePeriodText
    }
    
    private func getWeatherIconName(weather: Weather) -> String? {
        var wetherName: String
        
        switch weather.main {
        case "Clear":
            wetherName = WeatherTypeTranslator.translate(type: .clear)
        case "Clouds":
            wetherName = WeatherTypeTranslator.translate(type: .clouds)
        case "Rain":
            wetherName = WeatherTypeTranslator.translate(type: .rain)
        case "Snow":
            wetherName = WeatherTypeTranslator.translate(type: .snow)
        default:
            return nil
        }
        
        return wetherName
    }    
}

for文の中で受け取った天気データを現在時刻から24時間分に絞っています。

DateFormatHelper

getTimePeriodTextの「DateFormatHelper」で、受け取ったDateをDateFormatterで日本時間表記に整形しています。

DateFormatHelper.swift
import Foundation

final class DateFormatHelper {
    static let shared = DateFormatHelper()
    
    private let hourAndMinutesFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.locale = Locale(identifier: "Asia/Tokyo")
        formatter.dateFormat = "HH:mm"
        return formatter
    }()
    
    private let hourFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.locale = Locale(identifier: "Asia/Tokyo")
        formatter.dateFormat = "HH"
        return formatter
    }()
    
    func formatToHHmm(date: Date) -> String {
        return hourAndMinutesFormatter.string(from: date)
    }
    
    func formatToHH(date: Date) -> String {
        return hourFormatter.string(from: date)
    }
}

DateFormatHelperをテスト

「DateFormatHelper」のテストが書けそうなので書きました。

DateFormatHelperTests.swift
import XCTest

class DateFormatHelperTests: XCTestCase {
    private let morningTime = Date(timeIntervalSince1970: 1645056000) // 2022年2月17日 木曜日 09:00:00
    private let nightTime = Date(timeIntervalSince1970: 1645099200) // 2022年2月17日 木曜日 21:00:00

    override func setUpWithError() throws {
        // Put setup code here. This method is called before the invocation of each test method in the class.
    }

    override func tearDownWithError() throws {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }

    func testFormatToHHmm() throws {
        XCTContext.runActivity(named: "時刻が09:00の場合") { _ in
            let result = DateFormatHelper.shared.formatToHHmm(date: morningTime)
            XCTAssertEqual("09:00", result)
        }
        XCTContext.runActivity(named: "時刻が21:00の場合") { _ in
            let result = DateFormatHelper.shared.formatToHHmm(date: nightTime)
            XCTAssertEqual("21:00", result)
        }
    }
    
    func testFormatToHH() throws {
        XCTContext.runActivity(named: "時刻が09:00の場合") { _ in
            let result = DateFormatHelper.shared.formatToHH(date: morningTime)
            XCTAssertEqual("09", result)
        }
        XCTContext.runActivity(named: "時刻が21:00の場合") { _ in
            let result = DateFormatHelper.shared.formatToHH(date: nightTime)
            XCTAssertEqual("21", result)
        }
    }
}

WeatherTypeTranslator

getWeatherIconNameの「WeatherTypeTranslator」は、APIのレスポンスから天気アイコンの画像名を返す役割をしています。

WeatherType.swift
import Foundation

enum WeatherType: String {
    case clear
    case clouds
    case rain
    case snow
}
WeatherTypeTranslator.swift
import Foundation

final class WeatherTypeTranslator {
    static func translate(type: WeatherType) -> String {
        switch type {
        case .clear:
            return "hare_noon"
        case .clouds:
            return "cloudy"
        case .rain:
            return "rain"
        case .snow:
            return "snow"
        }
    }
}

WeatherTypeTranslatorをテスト

「WeatherTypeTranslator」のテストも書けそうなので書きました。

WeatherTypeTranslatorTests.swift
import XCTest

class WeatherTypeTranslatorTests: XCTestCase {

    override func setUpWithError() throws {
        // Put setup code here. This method is called before the invocation of each test method in the class.
    }

    override func tearDownWithError() throws {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }
    
    func testWeatherTypeTranslator() throws {
        let inputs: [WeatherType] = [.clear, .clouds, .rain, .snow]
        let expects = ["hare_noon", "cloudy", "rain", "snow"]
        
        for (index, input) in inputs.enumerated() {
            let expect = expects[index]
            let result = WeatherTypeTranslator.translate(type: input)
            XCTAssertEqual(expect, result)
        }
    }
}

EntryModelをテスト

「EntryModel」のテストも動作確認レベルで書きました。

MediumWidgetEntryModel.swift
import XCTest

class MediumWidgetEntryModelTests: XCTestCase {
    private let entryModel = MediumWidgetEntryModel(
        currentDate: Date(),
        hourlyWeathers: MockHourly.data,
        currentLocation: "世田谷区"
    )
    
    override func setUpWithError() throws {
        // Put setup code here. This method is called before the invocation of each test method in the class.
    }

    override func tearDownWithError() throws {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }
    
    func testInitializer() throws {
        let hourlyWeathers = entryModel.hourlyWeathers
        let currentLocation = entryModel.currentLocation
        let weatherIcons = entryModel.weatherIcons
        let temperatureTexts = entryModel.temperatureTexts
        let hourlyPressures = entryModel.hourlyPressures
        
        XCTAssert(hourlyWeathers.count == 24)
        XCTAssert(currentLocation == "世田谷区")
        XCTAssert(weatherIcons.count == 24)
        XCTAssert(temperatureTexts.count == 24)
        XCTAssert(hourlyPressures.count == 24)
    }

    func testGetTimePeriodText() throws {
        let hourlyWeathers = entryModel.hourlyWeathers
        
        let expects = [
            "・", "18", "・", "・", "21", "・",
            "・", "0", "・", "・", "3", "・",
            "・", "6", "・", "・", "9", "・",
            "・", "12", "・", "・", "15", "・",
            "・", "18", "・", "・", "21", "・"
        ]
        
        hourlyWeathers.enumerated().forEach { index, input in
            let expect = expects[index]
            let result = entryModel.getTimePeriodText(hourlyWeather: input)
            XCTAssertEqual(expect, result)
        }
    }
}

テストが成功して意図した動作をしているのであればEntryModelの開発は完了です。最後に「TimelineProvider」でタイムラインを作成します。

タイムラインを作成

ウィジェットのタイムラインは必要なデータを「TimelineProvider」で作成し、データをEntryModelに格納してそれを返すことで作成します。

天気情報のモックを作成

MockHourly.swift
import Foundation

struct MockHourly {
    static let data: [Hourly] = [
        Hourly(
            dt: 1644048000,
            temp: 5.77,
            pressure: 1005.0,
            weather: [Weather(main: "Clouds")]
        ),
        Hourly(
            dt: 1644051600, // 18:00
            temp: 5.49,
            pressure: 1007,
            weather: [Weather(main: "Clear")]
        ),
        Hourly(
            dt: 1644055200,
            temp: 4.85,
            pressure: 1009,
            weather: [Weather(main: "Snow")]
        ),
        Hourly(
            dt: 1644058800,
            temp: 4.13,
            pressure: 1011,
            weather: [Weather(main: "Clear")]
        ),
        Hourly(
            dt: 1644062400, // 21:00
            temp: 3.53,
            pressure: 1013,
            weather: [Weather(main: "Clouds")]
        ),
        ...

APIのレスポンスに合わせてモックを作成しました。

TimelineProviderの作成

RepositoryRocator.swift
import Foundation

class RepositoryRocator {
    
    static func getWeatherRepository() -> WeatherRepositoryInterface {
        WeatherRepository()
    }
    
    static func getUserRepository() -> UserRepositoryInterface {
        UserRepository()
    }
    
    static func getReverseGeocodeRepository() -> ReverseGeocodeRepositoryInterface {
        ReverseGeocodeRepository()
    }
}

ウィジェットで必要なデータを取得するにはRepositoryを経由して取得します。RepositoryはRepositoryRocatorを経由してRepositoryInterfaceから取得しています。

MediumWidgetProvider.swift
import WidgetKit
import SwiftUI

struct MediumWidgetProvider: TimelineProvider {
    private let weatherRepository = RepositoryRocator.getWeatherRepository()
    private let userRepository = RepositoryRocator.getUserRepository()
    private let reverseGeocodeRepository = RepositoryRocator.getReverseGeocodeRepository()
    
    func placeholder(in context: Context) -> MediumWidgetEntryModel {
        MediumWidgetEntryModel(
            currentDate: Date(),
            hourlyWeathers: MockHourly.data,
            currentLocation: "サンプル"
        )
    }

    func getSnapshot(in context: Context, completion: @escaping (MediumWidgetEntryModel) -> ()) {
        let entry = MediumWidgetEntryModel(
            currentDate: Date(),
            hourlyWeathers: MockHourly.data,
            currentLocation: "サンプル"
        )
        completion(entry)
    }
    
    actor Entries {
        var entries: [MediumWidgetEntryModel] = []
        
        func append(model: MediumWidgetEntryModel) {
            entries.append(model)
        }
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<MediumWidgetEntryModel>) -> ()) {
        let entries = Entries()
        let currentDate = Date()
        let currentEpoch = Int(currentDate.timeIntervalSince1970) // 現在時刻のepoch
        let justCurrentEpoch = Double(currentEpoch - (currentEpoch % 3600)) // 現在時のepoch xx:00
        let justCurrentHourDate = Date(timeIntervalSince1970: justCurrentEpoch)
        
        Task {
            let reverseGeocodeRequest = ReverseGeocodeRequestModel(lat: userRepository.lat, lng: userRepository.lng)
            let reversGeocodeResponce = try await reverseGeocodeRepository.fetchLocationFromLatLng(requestModel: reverseGeocodeRequest)
            
            let weatherRequest = WeatherRequestModel(lat: userRepository.lat, lng: userRepository.lng)
            let weatherResponse = try await weatherRepository.fetchWeathers(requestModel: weatherRequest)
            let hourlyWeathers = weatherResponse.hourly
            
            for index in 0..<5 {
                let entryModel = MediumWidgetEntryModel(
                    currentDate: justCurrentHourDate + Double((index * 3600)),
                    hourlyWeathers: hourlyWeathers,
                    currentLocation: reversGeocodeResponce.location
                )
                await entries.append(model: entryModel)
            }
            let entries = await entries.entries
            
            if let nextAttemptDate = Calendar.current.date(byAdding: DateComponents(hour: 1, minute: 5), to: justCurrentHourDate) {
                let timeline = Timeline(entries: entries, policy: .after(nextAttemptDate))
                completion(timeline)
            }
        }
    }
}

placeholdergetSnapshotで先ほど作成したモックを使っています。

タイムラインの作成はgetTimeLineでおこないます。
getTimeLineでは、緯度経度から地点名の取得、「Open Weather API」から天気情報の取得、actorを使って現在と未来4時間分のEntryModelの作成、タイムライン更新時間の設定をしています。今回はタイムラインの更新は毎時5分のタイミングでおこなうように設定しました。

また、今回EntryModelが生成できない場合のViewは開発しない予定なので、未来4時間分のEntryModelを作成することでひとまず対応しています。これでタイムラインの作成は完了です。

おわりに

今回はWidgetKitで天気予報アプリ作ってみた~タイムライン作成編~について書きました。
次回は、今回作成した「EntryModel」のデータを使ってViewの実装をした記事を書こうと思います。

続きが気になる方は↓こちら↓から

ご覧いただきありがとうございました。
こうしたほうがいいや、ここはちょっと違うなど気になる箇所があった場合、ご教示いただけると幸いです。質問も受け付けています。

お知らせ

現在副業でiOSアプリ開発を募集しています。
Twitter DMでご依頼お待ちしております🙂
QR_615427.png
↓活動リンクはこちら↓
https://linktr.ee/sasaki.ken

4
1
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
4
1