🌤️

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

2023/11/26に公開

初めに

今回は、前回に引き続き Open-Meteo API を使って天気情報を取得し、その情報によってUIを切り替える天気アプリを実装していきたいと思います。
Part 4 の今回は、それぞれの都市、天気の3Dモデルを表示させてみたいと思います。

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

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

Part 3
https://zenn.dev/koichi_51/articles/2566bcd753858a

記事の対象者

  • Swift, SwiftUI 学習者
  • Vision OS に触れてみたい方

完成イメージ

今回の完成イメージ

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

実装

前回までの実装

前回のコード全文
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?
    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
    }
}
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()
    }
}
TemperatureData
import Foundation

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

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

WeatherApp の変更

まずは WeatherRealityView を WeatherApp に追加します。
今回は東京の天気が晴れの時のみの表示をします。

WeatherApp
import SwiftUI

@main
struct WeatherApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
	
+       WindowGroup(id: "WeatherRealityView") {
+           WeatherRealityView(city: "Tokyo", weather: "Sunny")
+       }
+       .defaultSize(CGSize(width: 800, height: 1000))
    }
}

WeatherRealityView の作成

次に、各都市と天気に対応した3Dモデルを表示させる WeatherRealityView を作成します。
コードは以下のようになります。

WeatherRealityView
import SwiftUI
import RealityKit
import RealityKitContent

struct WeatherRealityView: View {
    let city: String
    let weather: String
    
    @State private var cityEntity: Entity?
    @State private var weatherEntity: Entity?
    let attachmentID = "attachmentID"
    var body: some View {
        RealityView { content, attachments in
            guard let cityEntity = try? await Entity(named: city, in: realityKitContentBundle) else {
                fatalError("Unable to load city scene model")
            }
            content.add(cityEntity)
            self.cityEntity = cityEntity
            
            guard let weatherEntity = try? await Entity(named: weather, in: realityKitContentBundle) else {
                fatalError("Unable to load weather scene model")
            }
            content.add(weatherEntity)
            self.weatherEntity = weatherEntity
            
            if let sceneAttachment = attachments.entity(for: attachmentID) {
                sceneAttachment.position = SIMD3<Float>(-0.2, -0.1, 0.1)
                content.add(sceneAttachment)
            }
        } update: { content, attachments in
            print("RealityView changes detected ...")
        } placeholder: {
            ProgressView()
                .progressViewStyle(.circular)
                .controlSize(.large)
                .frame(width: 800, height: 1000, alignment: .center)
        } attachments: {
            Attachment(id: attachmentID) {
                WeatherRealityDetailView(city: city, weather: weather)
            }
        }
    }
}

以下の部分では、都市名と天気を String で受け取っています。

let city: String
let weather: String

以下の部分では都市と天気のモデルを追加しています。
もしそれぞれの名前に合致するモデルが存在しなければエラーを返すようにしています。
今回は「東京の天気が晴れの場合」のみなので、Tokyo, Sunny の二つのモデルを RealityViewcontent に追加しています。

guard let cityEntity = try? await Entity(named: city, in: realityKitContentBundle) else {
    fatalError("Unable to load city scene model")
}
content.add(cityEntity)
self.cityEntity = cityEntity
            
guard let weatherEntity = try? await Entity(named: weather, in: realityKitContentBundle) else {
    fatalError("Unable to load weather scene model")
}
content.add(weatherEntity)
self.weatherEntity = weatherEntity

以下の部分では、都市と天気のモデルのアタッチメントを追加しています。
sceneAttachment.position を指定することで、モデルに対するアタッチメントの相対的な位置を指定することができます。

if let sceneAttachment = attachments.entity(for: attachmentID) {
    sceneAttachment.position = SIMD3<Float>(-0.2, -0.1, 0.1)
    content.add(sceneAttachment)
}

以下の部分では、アタッチメントのビューを追加しています。
詳細については後述します。

attachments: {
    Attachment(id: attachmentID) {
        WeatherRealityDetailView(city: city, weather: weather)
    }
}

WeatherRealityDetailView の作成

都市と天気の3Dモデルのアタッチメントである、WeatherRealityDetailView を実装してみましょう。コードは以下のようになります。
都市名と天気を VStack で並べている非常に簡素なUIになっています。

import SwiftUI

struct WeatherRealityDetailView: View {
    let city: String
    let weather: String
    var body: some View {
        VStack {
            Text(city)
            Text(weather)
        }
    }
}

ContentView の変更

最後に、作成した WeatherRealityView を表示させる処理を実装します。
ContentView に以下を追加します。

ContentView
import SwiftUI

struct ContentView: View {
+   @Environment(\.openWindow) private var openWindow
    @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")
+               .toolbar {
+                   ToolbarItem(placement: .navigationBarTrailing) {
+                       Button(action: {
+                           if weatherViewModel.weatherData != nil {
+                               openWindow(id: "WeatherRealityView")
+                           }
+                       }) {
+                           Text("Open in volume")
+                       }
+                   }
+               }
            }
        }
        .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
    }
}

これで以下のように、NavigationSplitView の右上に「Open in volume」ボタンが表示され、押すと東京の晴れの3Dモデルが表示されるはずです。

今回作成した Tokyo のモデルは以下のようなモデルです。

今回作成した Sunny のモデルは以下のようなモデルです。

今回は Volume として表示させることを想定しており、Volume の原点は新たに表示されるスクリーンの中心あたりにあることがわかったので、両方のモデルとも少し y軸を下げて表示させています。

これで以下の完成イメージにあった通り東京の晴れのモデルを表示させることができました。

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

コードは以下の GitHub で公開しているので、よろしければご覧ください。
https://github.com/Koichi5/weather-app

まとめ

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

4回に分けてAPIを使った天気アプリを実装してきました。
実装する中で気づくこともかなり多くあり、個人的に学びの多い実装になったなと思います。
誤っている点等あればご指摘いただけると幸いです。

Discussion