🌤️

【Swift】VisionOS で天気アプリを作ってみる Part 3

2023/11/26に公開

初めに

今回は、前回に引き続き Open-Meteo API を使って天気情報を取得し、その情報によってUIを切り替える天気アプリを実装していきたいと思います。
Part 3 の今回は、Charts を利用して、1日の気温変化をグラフで表せるようにしたいと思います。
今回の記事は前回の Part 1, 2 の続きになるので、一連の流れを掴む上でも以下の記事を参照して貰えばと思います。

Part 1
https://zenn.dev/koichi_51/articles/040206e0a15850

Part 2
https://zenn.dev/koichi_51/articles/b9b5e11171ba83

今回は主に Charts の使い方を中心に実装していこうと思います。

記事の対象者

  • Swift, SwiftUI 学習者
  • Vision OS に触れてみたい方
  • Swift でグラフの実装をしてみたい方

完成イメージ

全体の完成イメージ

https://www.youtube.com/watch?v=tbUJonsvek8

今回は日本各地の天気情報を取得し、そのデータに対応した画像を背景画像するビューを作成してみましょう。

今回の完成イメージ

https://youtu.be/gTDy5LyA08I

実装

前回までの実装

前回のコード全文
WeatherApp
import SwiftUI

@main
struct WeatherApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}
ContentView
import SwiftUI

struct ContentView: View {
    @ObservedObject var weatherViewModel = WeatherViewModel()
    @State private var selectedCity: City?
    init() {
        _selectedCity = State(initialValue: sampleCities[0])
    }
    var body: some View {
        NavigationSplitView {
            List(sampleCities, id: \.self, selection: $selectedCity) { city in
                Text(city.name)
            }
            .onChange(of: selectedCity) {
                weatherViewModel.fetchWeatherData(
                    latitude: String(selectedCity!.latitude),
                    longitude: String(selectedCity!.longitude),
                    timeZone: selectedCity!.timeZone
                )
            }
            .navigationTitle("City")
        } detail: {
            if let selectedCity {
                VStack {
                    Text(selectedCity.name)
                        .font(.system(size: 30))
                    if (weatherViewModel.weatherData != nil) {
                        Text("\(Int(weatherViewModel.weatherData!.current.temperature2M))°").font(.system(size: 60))
                        HStack {
                            VStack {
                                Text("最高気温")
                                    .font(.system(size: 30))
                                Text("\(Int(weatherViewModel.weatherData!.daily.temperature2MMax[0]))°").font(.system(size: 50))
                            }
                            VStack {
                                Text("最低気温")
                                    .font(.system(size: 30))
                                 Text("\(Int(weatherViewModel.weatherData!.daily.temperature2MMin[0]))°").font(.system(size: 50))
                            }
                        }
                        .padding(10)
                         Text("\(weatherDescription(from: weatherViewModel.weatherData!.current.weatherCode))").font(.system(size: 40))
                    } else {
                        Text("天気情報取得 ...")
                    }
                }
                .navigationTitle("Weather")
            }
        }
        .background {
            ZStack {
                if (weatherViewModel.weatherData != nil) {
                    Image("\(weatherDescriptionInEnglish(from: weatherViewModel.weatherData!.current.weatherCode))\(selectedCity!.name)")
                        .ignoresSafeArea()
                }
                Color.blue.opacity(0.8)
                    .blendMode(.softLight)
            }
        }
    }
}
City
import Foundation

struct City: Codable, Identifiable, Hashable {
    var id: String { name }
    let name: String
    let latitude: Double
    let longitude: Double
    let timeZone: String
    
    enum CodingKeys: String, CodingKey {
        case name, latitude, longitude
        case timeZone = "time_zone"
    }
}

let sampleCities = [
    City(name: "Tokyo", latitude: 35.6895, longitude: 139.6917, timeZone: "Asia%2FTokyo"),
    City(name: "Osaka", latitude: 34.686, longitude: 135.520, timeZone: "Asia%2FTokyo"),
    City(name: "Nagoya", latitude: 35.1709, longitude: 136.8815, timeZone: "Asia%2FTokyo"),
    City(name: "Hokkaido", latitude: 43.0614, longitude: 141.3538, timeZone: "Asia%2FTokyo"),
    City(name: "Okinawa", latitude: 26.2123, longitude: 127.6790, timeZone: "Asia%2FTokyo"),
]
Weather
import Foundation

// MARK: - Weather
struct Weather: Codable {
    let latitude, longitude, generationtimeMS: Double
    let utcOffsetSeconds: Int
    let timezone, timezoneAbbreviation: String
    let elevation: Int
    let currentUnits: Units
    let current: Current
    let hourlyUnits: Units
    let hourly: Hourly
    let dailyUnits: DailyUnits
    let daily: Daily

    enum CodingKeys: String, CodingKey {
        case latitude, longitude
        case generationtimeMS = "generationtime_ms"
        case utcOffsetSeconds = "utc_offset_seconds"
        case timezone
        case timezoneAbbreviation = "timezone_abbreviation"
        case elevation
        case currentUnits = "current_units"
        case current
        case hourlyUnits = "hourly_units"
        case hourly
        case dailyUnits = "daily_units"
        case daily
    }
}

// MARK: - Current
struct Current: Codable {
    let time: String
    let interval: Int
    let temperature2M: Double
    let rain, weatherCode: Int

    enum CodingKeys: String, CodingKey {
        case time, interval
        case temperature2M = "temperature_2m"
        case rain
        case weatherCode = "weather_code"
    }
}

// MARK: - Units
struct Units: Codable {
    let time: String
    let interval: String?
    let temperature2M, rain, weatherCode: String

    enum CodingKeys: String, CodingKey {
        case time, interval
        case temperature2M = "temperature_2m"
        case rain
        case weatherCode = "weather_code"
    }
}

// MARK: - Daily
struct Daily: Codable {
    let time: [String]
    let weatherCode: [Int]
    let temperature2MMax, temperature2MMin: [Double]

    enum CodingKeys: String, CodingKey {
        case time
        case weatherCode = "weather_code"
        case temperature2MMax = "temperature_2m_max"
        case temperature2MMin = "temperature_2m_min"
    }
}

// MARK: - DailyUnits
struct DailyUnits: Codable {
    let time, weatherCode, temperature2MMax, temperature2MMin: String

    enum CodingKeys: String, CodingKey {
        case time
        case weatherCode = "weather_code"
        case temperature2MMax = "temperature_2m_max"
        case temperature2MMin = "temperature_2m_min"
    }
}

// MARK: - Hourly
struct Hourly: Codable {
    let time: [String]
    let temperature2M: [Double]
    let rain, weatherCode: [Int]

    enum CodingKeys: String, CodingKey {
        case time
        case temperature2M = "temperature_2m"
        case rain
        case weatherCode = "weather_code"
    }
}

func weatherDescription(from code: Int) -> String {
    switch code {
    case 0:
        return "晴れ"
    case 1, 2, 3:
        return "主に晴れ、一部曇り、曇り"
    case 45, 48:
        return "霧、着氷霧"
    case 51, 53, 55:
        return "霧雨: 弱い、中程度、強い"
    case 56, 57:
        return "着氷性の霧雨: 弱い、強い"
    case 61, 63, 65:
        return "雨: 小雨、中雨、大雨"
    case 66, 67:
        return "着氷性の雨: 弱い、強い"
    case 71, 73, 75:
        return "雪: 小雪、中雪、大雪"
    case 77:
        return "霙(みぞれ)"
    case 80, 81, 82:
        return "にわか雨: 弱い、中程度、激しい"
    case 85, 86:
        return "にわか雪: 弱い、強い"
    case 95:
        return "雷雨: 弱いまたは中程度"
    case 96, 99:
        return "雷雨: 小さい、大きいひょうを伴う"
    default:
        return "不明"
    }
}

func weatherDescriptionInEnglish(from code: Int) -> String {
    switch code {
    case 0:
        return "Sunny"
    case 1, 2, 3:
        return "Cloudy"
    case 45, 48:
        return "Fog"
    case 51, 53, 55:
        return "Drizzle"
    case 56, 57:
        return "FreezingDrizzle"
    case 61, 63, 65:
        return "Rain"
    case 66, 67:
        return "FreezingRain"
    case 71, 73, 75:
        return "Snow"
    case 77:
        return "Sleet"
    case 80, 81, 82:
        return "Showers"
    case 85, 86:
        return "SnowShowers"
    case 95:
        return "Thunderstorm"
    case 96, 99:
        return "ThunderstormWithHail"
    default:
        return "Unknown"
    }
}
WeatherViewModel
import SwiftUI
import Foundation

class WeatherViewModel: ObservableObject {
    @Published var weatherData: Weather?
    
    func fetchWeatherData(latitude: String, longitude: String, timeZone: String) {
        let urlString = "https://api.open-meteo.com/v1/forecast?latitude=\(latitude)&longitude=\(longitude)&current=temperature_2m,rain,weather_code&hourly=temperature_2m,rain,weather_code&daily=weather_code,temperature_2m_max,temperature_2m_min&timezone=\(timeZone)"
        
        guard let url = URL(string: urlString) else {
            print("Invalid URL")
            return
        }
        
        URLSession.shared.dataTask(with: url) { data, _, error in
            DispatchQueue.main.async {
                if let data = data {
                    do {
                        let decodedData = try JSONDecoder().decode(Weather.self, from: data)
                        self.weatherData = decodedData
                    } catch {
                        print("Error decoding: \(error)")
                    }
                } else if let error = error {
                    print("Error fetching data: \(error)")
                }
            }
        }.resume()
    }
}

日にちのカード作成

まずは1時間ごとの天気予報をまとめるためのモデルを作成します。。
1時間ごとの天気予報は、以下の四つの情報を持っています。

  • 時間
  • 気温
  • 日付
  • 天気コード
HourlyForecast
import Foundation

struct HourlyForecast: Hashable {
    let time: String
    let temperature: Double
    var day: String {
        String(time.prefix(10))
    }
    let weatherCode: Int
}

次に、1時間ごとの予報の情報をリストとしてまとめた groupedWeather という変数を定義しておきます。groupedWeather では weatherViewModel.weatherData から1時間ごとの天気情報を time をもとに切り出して、時間、気温、天気コードを HourlyForecast に代入しています。

var groupedWeather: [String: [HourlyForecast]] {
    Dictionary(grouping: weatherViewModel.weatherData?.hourly.time.enumerated().map { (index, time) in
        HourlyForecast(
	    time: time, 
	    temperature:  weatherViewModel.weatherData!.hourly.temperature2M[index],
	    weatherCode: weatherViewModel.weatherData!.hourly.weatherCode[index]
	)
    } ?? [], by: { $0.day })
}

次にContentView の VStack の一番下に以下を追加します。
以下のコードでは、1週間の天気を表示させるためのカードの ScrollView を実装しています。

VStack(alignment: .leading){
    Text("週間天気")
	.font(.system(size: 30))
    ScrollView(.horizontal, showsIndicators: false) {
	HStack {
	    ForEach(groupedWeather.keys.sorted(), id: \.self) { day in
	        if let forecasts = groupedWeather[day] {
		    let formattedMonth = getMonth(from: day)
		    let formattedDay = getDay(from: day)
		    DailyWeatherCardView(month: formattedMonth, day: formattedDay, hourlyForecast: forecasts)
		}
	    }
	}
    }
}
.padding()

ただ、groupedWeather.keys は yyyy-MM-dd形式の String であるため、そこから月日のみを抽出するメソッドも追加する必要があります。

// yyyy-MM-dd to MM月dd日(String)
func formatDate(_ dateString: String) -> String {
    let inputFormatter = DateFormatter()
    inputFormatter.dateFormat = "yyyy-MM-dd"
    
    let outputFormatter = DateFormatter()
    outputFormatter.locale = Locale(identifier: "ja_JP")
    outputFormatter.dateFormat = "M月d日"
    
    if let date = inputFormatter.date(from: dateString) {
        return outputFormatter.string(from: date)
    } else {
        return "不正な日付"
    }
}

// yyyy-MM-dd to MM(Int)
func getMonth(from dateString: String) -> Int? {
    let formatter = DateFormatter()
    formatter.dateFormat = "yyyy-MM-dd"
    
    if let date = formatter.date(from: dateString) {
        let calendar = Calendar.current
        let month = calendar.component(.month, from: date)
        return month
    } else {
        return nil
    }
}

// yyyy-MM-dd to dd(Int)
func getDay(from dateString: String) -> Int? {
    let formatter = DateFormatter()
    formatter.dateFormat = "yyyy-MM-dd"
    
    if let date = formatter.date(from: dateString) {
        let calendar = Calendar.current
        let day = calendar.component(.day, from: date)
        return day
    } else {
        return nil
    }
}
変更後の ContentView
ContentView
import SwiftUI
struct ContentView: View {
    @ObservedObject var weatherViewModel = WeatherViewModel()
    @State private var selectedCity: City?
    var groupedWeather: [String: [HourlyForecast]] {
        Dictionary(grouping: weatherViewModel.weatherData?.hourly.time.enumerated().map { (index, time) in
            HourlyForecast(time: time, temperature: weatherViewModel.weatherData!.hourly.temperature2M[index], weatherCode: weatherViewModel.weatherData!.hourly.weatherCode[index])
        } ?? [], by: { $0.day })
    }
    
    init() {
        _selectedCity = State(initialValue: sampleCities[0])
    }
    
    var body: some View {
        NavigationSplitView {
            List(sampleCities, id: \.self, selection: $selectedCity) { city in
                Text(city.name)
            }
            .onChange(of: selectedCity) {
                weatherViewModel.fetchWeatherData(
                    latitude: String(selectedCity!.latitude),
                    longitude: String(selectedCity!.longitude),
                    timeZone: selectedCity!.timeZone
                )
            }
            .navigationTitle("City")
        } detail: {
            if let selectedCity {
                VStack {
                    Text(selectedCity.name)
                        .font(.system(size: 30))
                    if (weatherViewModel.weatherData != nil) {
                        Text("\(Int(weatherViewModel.weatherData!.current.temperature2M))°").font(.system(size: 60))
                        HStack {
                            VStack {
                                Text("最高気温")
                                    .font(.system(size: 30))
                                Text("\(Int(weatherViewModel.weatherData!.daily.temperature2MMax[0]))°").font(.system(size: 50))
                            }
                            VStack {
                                Text("最低気温")
                                    .font(.system(size: 30))
                                 Text("\(Int(weatherViewModel.weatherData!.daily.temperature2MMin[0]))°").font(.system(size: 50))
                            }
                        }
                        .padding(10)
                         Text("\(weatherDescription(from: weatherViewModel.weatherData!.current.weatherCode))").font(.system(size: 40))
                        VStack(alignment: .leading){
                            Text("週間天気")
                                .font(.system(size: 30))
                            ScrollView(.horizontal, showsIndicators: false) {
                                HStack {
                                    ForEach(groupedWeather.keys.sorted(), id: \.self) { day in
                                        if let forecasts = groupedWeather[day] {
                                            let formattedMonth = getMonth(from: day)
                                            let formattedDay = getDay(from: day)
                                            DailyWeatherCardView(month: formattedMonth, day: formattedDay, hourlyForecast: forecasts)
                                        }
                                    }
                                }
                            }
                        }
                        .padding()
                    } else {
                        Text("天気情報取得 ...")
                    }
                }
                .navigationTitle("Weather")
            }
        }
        .background {
            ZStack {
                if (weatherViewModel.weatherData != nil) {
                    Image("\(weatherDescriptionInEnglish(from: weatherViewModel.weatherData!.current.weatherCode))\(selectedCity!.name)")
                        .ignoresSafeArea()
                }
                Color.blue.opacity(0.8)
                    .blendMode(.softLight)
            }
        }
    }
}

// yyyy-MM-dd to MM月dd日(String)
func formatDate(_ dateString: String) -> String {
    let inputFormatter = DateFormatter()
    inputFormatter.dateFormat = "yyyy-MM-dd"
    
    let outputFormatter = DateFormatter()
    outputFormatter.locale = Locale(identifier: "ja_JP")
    outputFormatter.dateFormat = "M月d日"
    
    if let date = inputFormatter.date(from: dateString) {
        return outputFormatter.string(from: date)
    } else {
        return "不正な日付"
    }
}

// yyyy-MM-dd to MM(Int)
func getMonth(from dateString: String) -> Int? {
    let formatter = DateFormatter()
    formatter.dateFormat = "yyyy-MM-dd"
    
    if let date = formatter.date(from: dateString) {
        let calendar = Calendar.current
        let month = calendar.component(.month, from: date)
        return month
    } else {
        return nil
    }
}

// yyyy-MM-dd to dd(Int)
func getDay(from dateString: String) -> Int? {
    let formatter = DateFormatter()
    formatter.dateFormat = "yyyy-MM-dd"
    
    if let date = formatter.date(from: dateString) {
        let calendar = Calendar.current
        let day = calendar.component(.day, from: date)
        return day
    } else {
        return nil
    }
}

最後に DailyWeatherCardView を作成します。
コードは以下のようになります。
ラベルを日付としているカードのようなUIになっています。
ナビゲーションの遷移先としては DailyWeatherView を指定していて、このページで気温変化のグラフを作成して表示させたいと思います。

DailyWeatherCardView
import SwiftUI

struct DailyWeatherCardView: View {
    let month: Int?
    let day: Int?
    let hourlyForecast: [HourlyForecast]
    private let width: CGFloat = 100
    private let height: CGFloat = 100
    
    var body: some View {
        NavigationStack {
            NavigationLink (
                destination: {
                    DailyWeatherView(hourlyForecast: hourlyForecast, month: month ?? 0, day: day ?? 0)
                },
                label: {
                    if (month != nil && day != nil) {
                        Text(String(day!))
                            .font(.system(size: 50))
                            .frame(width: width, height: height)
                            .clipShape(Rectangle())
                            .cornerRadius(20)
                    } else {
                        Text("日付を取得できません")
                    }
                })
        }
    }
}

DailyWeatherView の作成

次に天気の変化のグラフを表示させる DailyWeatherView を作成します。
コードは以下のようになります。

DailyWeatherView
import SwiftUI
import Charts

struct DailyWeatherView: View {
    let hourlyForecast: [HourlyForecast]
    let month: Int
    let day: Int
    @State private var timeList: [Int] = []
    @State private var temperatureList: [Double] = []
    @State private var temperatureData: [TemperatureData] = []
    
    var body: some View {
        ScrollView {
            VStack {
                Chart(temperatureData) { dataRow in
                    LineMark(x: .value("Day", dataRow.day), y: .value("Value", dataRow.value))
                        .interpolationMethod(.catmullRom)
                        .foregroundStyle(Color.white)
                        .lineStyle(StrokeStyle(lineWidth: 5))
                        .symbol(.circle)
                }
                .frame(width: 700, height: 400)
            }
        }
        .navigationTitle("\(month)\(day)日")
        .onAppear {
            for (_, forecast) in hourlyForecast.enumerated() {
                timeList.append(extractHour(from: forecast.time) ?? 0)
                temperatureList.append(forecast.temperature)
            }
            for i in 0..<timeList.count {
                temperatureData.append(.init(day: timeList[i], value: temperatureList[i]))
            }
        }
    }
}

// yyyy-MM-dd'T'HH:mm to HH(Int)
func extractHour(from dateString: String) -> Int? {
    let dateFormatter = DateFormatter()
    dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm"
    
    if let date = dateFormatter.date(from: dateString) {
        let calendar = Calendar.current
        let hour = calendar.component(.hour, from: date)
        return hour
    } else {
        return nil
    }
}

また、グラフを表示させるにあたって、以下のモデルも作成する必要があります。
HourlyForecast よりも絞ったデータを保持するようにしています。

TemperatureData
import Foundation

struct TemperatureData: Identifiable {
    var id: Int { day }
    var day: Int
    var value: Double
}

以下の部分では、先ほど作成した TemperatureData をもとにグラフを作成しています。
LineMark とすることで折れ線グラフとして表現することができます。
また、.foregroundStyle(Color.white) とすることでグラフの色を白色にしています。

Chart(temperatureData) { dataRow in
    LineMark(x: .value("Day", dataRow.day), y: .value("Value", dataRow.value))
        .interpolationMethod(.catmullRom)
	.foregroundStyle(Color.white)
	.lineStyle(StrokeStyle(lineWidth: 5))
	.symbol(.circle)

以下の部分では yyyy-MM-dd'T'HH:mm 形式の String から Int 型の時間のみを返しています。

func extractHour(from dateString: String) -> Int? {
    let dateFormatter = DateFormatter()
    dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm"
    
    if let date = dateFormatter.date(from: dateString) {
        let calendar = Calendar.current
        let hour = calendar.component(.hour, from: date)
        return hour
    } else {
        return nil
    }
}

これで以下のようにグラフまで表示できるようになりました。

https://youtu.be/gTDy5LyA08I

Charts の補足

  • AreaMark
    折れ線グラフの下の部分を塗りつぶしたようなグラフになります。

  • PointMark
    それぞれの値をポイントで表したようなグラフになります。
    折れ線グラフの途中の線がないようなグラフです。

  • RectangleMark
    非常に細かいですが、PointMark のポイントが四角形になっているようなグラフになります。

  • BarMark
    名前の通り棒グラフになります。

まとめ

最後まで読んでいただいてありがとうございました。

今回はそれぞれの都市の気温の変化をグラフで表すUIを作成しました。
今回紹介したグラフ以外にもかなり柔軟にカスタマイズできるため、使用する場面があれば追加で学習してみたいと思います。

参考

https://open-meteo.com/en/docs

https://developer.apple.com/documentation/charts

Discussion