【Swift】VisionOS で天気アプリを作ってみる Part 3
初めに
今回は、前回に引き続き Open-Meteo API を使って天気情報を取得し、その情報によってUIを切り替える天気アプリを実装していきたいと思います。
Part 3 の今回は、Charts を利用して、1日の気温変化をグラフで表せるようにしたいと思います。
今回の記事は前回の Part 1, 2 の続きになるので、一連の流れを掴む上でも以下の記事を参照して貰えばと思います。
Part 1
Part 2
今回は主に Charts の使い方を中心に実装していこうと思います。
記事の対象者
- Swift, SwiftUI 学習者
- Vision OS に触れてみたい方
- Swift でグラフの実装をしてみたい方
完成イメージ
全体の完成イメージ
今回は日本各地の天気情報を取得し、そのデータに対応した画像を背景画像するビューを作成してみましょう。
今回の完成イメージ
実装
前回までの実装
前回のコード全文
import SwiftUI
@main
struct WeatherApp: App {
var body: some Scene {
WindowGroup {
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)
}
}
}
}
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"),
]
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"
}
}
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)¤t=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時間ごとの天気予報は、以下の四つの情報を持っています。
- 時間
- 気温
- 日付
- 天気コード
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
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
を指定していて、このページで気温変化のグラフを作成して表示させたいと思います。
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
を作成します。
コードは以下のようになります。
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
よりも絞ったデータを保持するようにしています。
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
}
}
これで以下のようにグラフまで表示できるようになりました。
Charts の補足
-
AreaMark
折れ線グラフの下の部分を塗りつぶしたようなグラフになります。
-
PointMark
それぞれの値をポイントで表したようなグラフになります。
折れ線グラフの途中の線がないようなグラフです。
-
RectangleMark
非常に細かいですが、PointMark
のポイントが四角形になっているようなグラフになります。
-
BarMark
名前の通り棒グラフになります。
まとめ
最後まで読んでいただいてありがとうございました。
今回はそれぞれの都市の気温の変化をグラフで表すUIを作成しました。
今回紹介したグラフ以外にもかなり柔軟にカスタマイズできるため、使用する場面があれば追加で学習してみたいと思います。
参考
Discussion