【Swift】VisionOS で天気アプリを作ってみる Part 4
初めに
今回は、前回に引き続き Open-Meteo API を使って天気情報を取得し、その情報によってUIを切り替える天気アプリを実装していきたいと思います。
Part 4 の今回は、それぞれの都市、天気の3Dモデルを表示させてみたいと思います。
Part 1
Part 2
Part 3
記事の対象者
- Swift, SwiftUI 学習者
- Vision OS に触れてみたい方
完成イメージ
今回の完成イメージ
実装
前回までの実装
前回のコード全文
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?
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
}
}
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()
}
}
import Foundation
struct TemperatureData: Identifiable {
var id: Int { day }
var day: Int
var value: Double
}
import Foundation
struct HourlyForecast: Hashable {
let time: String
let temperature: Double
var day: String {
String(time.prefix(10))
}
let weatherCode: Int
}
WeatherApp の変更
まずは WeatherRealityView
を 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
を作成します。
コードは以下のようになります。
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 の二つのモデルを RealityView
の content
に追加しています。
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 に以下を追加します。
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軸を下げて表示させています。
これで以下の完成イメージにあった通り東京の晴れのモデルを表示させることができました。
コードは以下の GitHub で公開しているので、よろしければご覧ください。
まとめ
最後まで読んでいただいてありがとうございました。
4回に分けてAPIを使った天気アプリを実装してきました。
実装する中で気づくこともかなり多くあり、個人的に学びの多い実装になったなと思います。
誤っている点等あればご指摘いただけると幸いです。
Discussion