投稿の経緯
前回投稿したWidgetKitで天気予報アプリ作ってみた~Widget Extension追加編~の続編です。
今回はウィジェットで使うデータを作成するタイムラインを開発していきます。
前回の記事を見てない方は先に↓こちら↓を確認してください。
環境
Swift 5.5
Xcode 13.2.1
サンプルプロジェクト
GitHubにPushしています。気になる方はご覧ください。
https://github.com/ken-sasaki-222/WeatherWidget
AppGroupsの追加
UserDefaultsに保存したデータを「Widget Extension」と共有したいので「AppGroups」を追加します。
参考になる記事を添付しておきます。
「App Groups」の設定が完了したらUserDefaultsDataStoreを書き換えます。
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作成
import Foundation
struct ReverseGeocodeRequestModel {
var lat: Double
var lng: Double
}
UserDefaultsDataStoreから取得した緯度経度を格納するモデルです。
レスポンスModel作成
import Foundation
struct ReverseGeocodeResponceModel {
var location: String
}
緯度経度から取得した地点名を格納するモデルです。
DataStore作成
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で非同期処理を書いています。
実際に緯度経度から地点名が返ってくるかどうかの確認をしたいので、ユニットテストも書きました。
import Foundation
class TestHelper {
static let lat = 35.646543998983674
static let lng = 139.62974883727196
}
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作成
import Foundation
protocol ReverseGeocodeRepositoryInterface {
func fetchLocationFromLatLng(requestModel: ReverseGeocodeRequestModel) async throws -> ReverseGeocodeResponceModel
}
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
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で日本時間表記に整形しています。
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」のテストが書けそうなので書きました。
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のレスポンスから天気アイコンの画像名を返す役割をしています。
import Foundation
enum WeatherType: String {
case clear
case clouds
case rain
case snow
}
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」のテストも書けそうなので書きました。
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」のテストも動作確認レベルで書きました。
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に格納してそれを返すことで作成します。
天気情報のモックを作成
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の作成
import Foundation
class RepositoryRocator {
static func getWeatherRepository() -> WeatherRepositoryInterface {
WeatherRepository()
}
static func getUserRepository() -> UserRepositoryInterface {
UserRepository()
}
static func getReverseGeocodeRepository() -> ReverseGeocodeRepositoryInterface {
ReverseGeocodeRepository()
}
}
ウィジェットで必要なデータを取得するにはRepositoryを経由して取得します。RepositoryはRepositoryRocatorを経由してRepositoryInterfaceから取得しています。
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)
}
}
}
}
placeholder
とgetSnapshot
で先ほど作成したモックを使っています。
タイムラインの作成はgetTimeLine
でおこないます。
getTimeLine
では、緯度経度から地点名の取得、「Open Weather API」から天気情報の取得、actorを使って現在と未来4時間分のEntryModelの作成、タイムライン更新時間の設定をしています。今回はタイムラインの更新は毎時5分のタイミングでおこなうように設定しました。
また、今回EntryModelが生成できない場合のViewは開発しない予定なので、未来4時間分のEntryModelを作成することでひとまず対応しています。これでタイムラインの作成は完了です。
おわりに
今回はWidgetKitで天気予報アプリ作ってみた~タイムライン作成編~について書きました。
次回は、今回作成した「EntryModel」のデータを使ってViewの実装をした記事を書こうと思います。
続きが気になる方は↓こちら↓から
ご覧いただきありがとうございました。
こうしたほうがいいや、ここはちょっと違うなど気になる箇所があった場合、ご教示いただけると幸いです。質問も受け付けています。
お知らせ
現在副業でiOSアプリ開発を募集しています。
Twitter DMでご依頼お待ちしております🙂
↓活動リンクはこちら↓
https://linktr.ee/sasaki.ken