【Swift】VisionOS で天気アプリを作ってみる Part 1
初めに
今回は、Open-Meteo API を使って天気情報を取得し、その情報によってUIを切り替える天気アプリを実装していきたいと思います。
Part 1 の今回は、APIを叩いて情報を表示させるまでを実装してみたいと思います。
記事の対象者
- Swift, SwiftUI 学習者
- Vision OS に触れてみたい方
- Swift で API の実装をしてみたい方
完成イメージ
全体の完成イメージ
今回はAPIを叩いて東京の天気を取得して、表示させるところまで実装してみましょう。
今回の完成イメージ
実装
プロジェクトの作成
今回は「WeatherApp」という名前でプロジェクトを作成していきます。
以下の画面ではすでに同じ名前のプロジェクトが存在しますが、初めから作成するため、「Create New Project...」を選択します。
「VisionOS」 の 「App」 を選択します。
「Product Name」は「WeatherApp」としておきます。
WeatherApp
WeatherApp のコードの全文は以下のようになります。
import SwiftUI
@main
struct WeatherApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
表示に関しては主に ContentView
を編集していきます。
WeatherModel
Open-Meteo API は特にサインインも必要なく、天気データを取得できるAPIであるため、正しいフォーマットであればAPIを叩くことはできるかと思います。しかし、取得したデータをアプリ内で正常に使用するためには、正しい型でデータを取得する必要があります。そこで、アプリ内で使用する独自のモデルを定義していきます。
Open-Meteo API ドキュメントを確認すると以下の項目を設定することができます。
- 天気情報を取得したい地点の座標、タイムゾーン
- 取得したい天気予報の日数
- 現在の天気で取得したい変数(気温、降水量など様々)
- 1時間ごとの天気で取得したい変数(気温、降水量など様々)
- 1日ごとの天気で取得したい変数(最高気温、最低気温、降水量など様々)
また、項目を設定すると、それを取得するためのURLまで作成してくれます。他のAPIドキュメントだとプロパティの説明のみのものも多いため、非常に親切であると感じました。
今回はまず東京の天気に関して以下の項目を取得してみたいと思います。
- 現在の気温、降水量、天気コード
- 1時間ごとの気温、降水量、天気コード
- 1日ごとの天気コード、最高気温、最低気温
それぞれの項目にチェックを入れてURLを生成してみると以下のようになります。長いURLですが、よく内容を見てみると、緯度経度が含まれていたり、現在、1時間ごと、1日ごととパラメーターが分かれていたりすることがわかります。
https://api.open-meteo.com/v1/forecast?latitude=35.6895&longitude=139.6917¤t=temperature_2m,rain,weather_code&hourly=temperature_2m,rain,weather_code&daily=weather_code,temperature_2m_max,temperature_2m_min&timezone=Asia%2FTokyo
このURLを Google などのブラウザに入れてみると以下のようなレスポンスがあることがわかります。
1行で表示されてしまうためわかりにくいですが、非常に長いレスポンスであることがわかります。
今回はこのレスポンスに対応する独自のモデルを作成していきます。
{"latitude":35.7,"longitude":139.6875,"generationtime_ms":0.08809566497802734,"utc_offset_seconds":32400,"timezone":"Asia/Tokyo","timezone_abbreviation":"JST","elevation":40.0,"current_units":{"time":"iso8601","interval":"seconds","temperature_2m":"°C","rain":"mm","weather_code":"wmo code"},"current":{"time":"2023-11-25T14:00","interval":900,"temperature_2m":11.7,"rain":0.00,"weather_code":0},"hourly_units":{"time":"iso8601","temperature_2m":"°C","rain":"mm","weather_code":"wmo code"},"hourly":{"time":["2023-11-25T00:00","2023-11-25T01:00","2023-11-25T02:00","2023-11-25T03:00","2023-11-25T04:00","2023-11-25T05:00","2023-11-25T06:00","2023-11-25T07:00","2023-11-25T08:00","2023-11-25T09:00","2023-11-25T10:00","2023-11-25T11:00","2023-11-25T12:00","2023-11-25T13:00","2023-11-25T14:00","2023-11-25T15:00","2023-11-25T16:00","2023-11-25T17:00","2023-11-25T18:00","2023-11-25T19:00","2023-11-25T20:00","2023-11-25T21:00","2023-11-25T22:00","2023-11-25T23:00","2023-11-26T00:00","2023-11-26T01:00","2023-11-26T02:00","2023-11-26T03:00","2023-11-26T04:00","2023-11-26T05:00","2023-11-26T06:00","2023-11-26T07:00","2023-11-26T08:00","2023-11-26T09:00","2023-11-26T10:00","2023-11-26T11:00","2023-11-26T12:00","2023-11-26T13:00","2023-11-26T14:00","2023-11-26T15:00","2023-11-26T16:00","2023-11-26T17:00","2023-11-26T18:00","2023-11-26T19:00","2023-11-26T20:00","2023-11-26T21:00","2023-11-26T22:00","2023-11-26T23:00","2023-11-27T00:00","2023-11-27T01:00","2023-11-27T02:00","2023-11-27T03:00","2023-11-27T04:00","2023-11-27T05:00","2023-11-27T06:00","2023-11-27T07:00","2023-11-27T08:00","2023-11-27T09:00","2023-11-27T10:00","2023-11-27T11:00","2023-11-27T12:00","2023-11-27T13:00","2023-11-27T14:00","2023-11-27T15:00","2023-11-27T16:00","2023-11-27T17:00","2023-11-27T18:00","2023-11-27T19:00","2023-11-27T20:00","2023-11-27T21:00","2023-11-27T22:00","2023-11-27T23:00","2023-11-28T00:00","2023-11-28T01:00","2023-11-28T02:00","2023-11-28T03:00","2023-11-28T04:00","2023-11-28T05:00","2023-11-28T06:00","2023-11-28T07:00","2023-11-28T08:00","2023-11-28T09:00","2023-11-28T10:00","2023-11-28T11:00","2023-11-28T12:00","2023-11-28T13:00","2023-11-28T14:00","2023-11-28T15:00","2023-11-28T16:00","2023-11-28T17:00","2023-11-28T18:00","2023-11-28T19:00","2023-11-28T20:00","2023-11-28T21:00","2023-11-28T22:00","2023-11-28T23:00","2023-11-29T00:00","2023-11-29T01:00","2023-11-29T02:00","2023-11-29T03:00","2023-11-29T04:00","2023-11-29T05:00","2023-11-29T06:00","2023-11-29T07:00","2023-11-29T08:00","2023-11-29T09:00","2023-11-29T10:00","2023-11-29T11:00","2023-11-29T12:00","2023-11-29T13:00","2023-11-29T14:00","2023-11-29T15:00","2023-11-29T16:00","2023-11-29T17:00","2023-11-29T18:00","2023-11-29T19:00","2023-11-29T20:00","2023-11-29T21:00","2023-11-29T22:00","2023-11-29T23:00","2023-11-30T00:00","2023-11-30T01:00","2023-11-30T02:00","2023-11-30T03:00","2023-11-30T04:00","2023-11-30T05:00","2023-11-30T06:00","2023-11-30T07:00","2023-11-30T08:00","2023-11-30T09:00","2023-11-30T10:00","2023-11-30T11:00","2023-11-30T12:00","2023-11-30T13:00","2023-11-30T14:00","2023-11-30T15:00","2023-11-30T16:00","2023-11-30T17:00","2023-11-30T18:00","2023-11-30T19:00","2023-11-30T20:00","2023-11-30T21:00","2023-11-30T22:00","2023-11-30T23:00","2023-12-01T00:00","2023-12-01T01:00","2023-12-01T02:00","2023-12-01T03:00","2023-12-01T04:00","2023-12-01T05:00","2023-12-01T06:00","2023-12-01T07:00","2023-12-01T08:00","2023-12-01T09:00","2023-12-01T10:00","2023-12-01T11:00","2023-12-01T12:00","2023-12-01T13:00","2023-12-01T14:00","2023-12-01T15:00","2023-12-01T16:00","2023-12-01T17:00","2023-12-01T18:00","2023-12-01T19:00","2023-12-01T20:00","2023-12-01T21:00","2023-12-01T22:00","2023-12-01T23:00"],"temperature_2m":[10.1,9.1,8.6,8.6,7.8,7.4,7.5,7.1,7.8,9.4,10.2,10.9,11.4,11.7,11.7,11.3,10.4,9.3,8.6,7.9,7.6,7.2,6.9,6.7,6.5,6.2,6.1,5.9,5.8,5.6,5.3,5.2,5.4,6.1,6.9,7.9,8.8,9.2,9.5,9.6,9.4,9.1,9.0,8.9,8.9,8.8,8.6,8.4,8.1,8.1,7.8,7.4,7.0,6.4,6.2,6.2,7.2,8.9,10.9,12.4,13.8,14.6,15.1,15.1,14.4,13.1,12.6,11.9,11.4,10.9,10.3,9.6,9.1,9.0,9.1,9.2,10.8,12.9,13.4,13.1,14.4,17.0,18.9,19.8,20.4,20.9,20.9,20.1,19.8,17.8,15.9,14.6,13.4,12.3,11.3,10.3,9.6,9.3,9.2,8.9,7.9,6.7,6.2,6.6,7.7,9.0,10.6,12.6,14.0,14.8,15.1,14.9,14.0,12.6,11.4,10.4,9.5,8.8,8.1,7.6,7.2,6.8,6.4,6.1,5.6,5.2,5.2,5.7,6.7,8.0,9.7,11.6,13.1,13.7,13.8,13.6,12.8,11.7,10.5,9.5,8.6,7.7,7.0,6.4,5.9,5.4,5.1,4.7,4.3,3.8,3.8,4.4,5.4,6.7,8.2,10.0,11.3,12.0,12.4,12.2,11.4,10.2,9.2,8.7,8.4,8.2,8.0,7.9],"rain":[0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00],"weather_code":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,3,3,3,2,1,1,2,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,2,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,2,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,1,1,1,2,2,2,0,0,0,1,1,1,0,0,0,0,0,0,0,0]},"daily_units":{"time":"iso8601","weather_code":"wmo code","temperature_2m_max":"°C","temperature_2m_min":"°C"},"daily":{"time":["2023-11-25","2023-11-26","2023-11-27","2023-11-28","2023-11-29","2023-11-30","2023-12-01"],"weather_code":[1,3,2,2,2,2,2],"temperature_2m_max":[11.7,9.6,15.1,20.9,15.1,13.8,12.4],"temperature_2m_min":[6.7,5.2,6.2,9.0,6.2,5.2,3.8]}}
とはいっても、1から作るとなるとレスポンスを全て読んで構造を理解する必要があり、非常に時間がかかります。
そこで以下のサイトを使用します。
このサイトではAPIを叩いた際のレスポンスをSwiftの型として扱うために、すべてのデータを構造化された独自の型に変換してくれます。
個人的には Dart の変換もサポートしていたのが嬉しいポイントだったので、今度Flutterの方でも使ってみようかと思いました。
上記のサイトで作成した独自の型が以下のようになっています。
Current, Daily, Hourly をうまく拾って、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"
}
}
これで API のレスポンスに対応した独自の型の作成は完了です。
Modelに関しては Models / Weather, Daily, Hourly のようにファイルを分けてもいいかなと思います。
WeatherViewModel
次に Open-Meteo API を叩いて天気を取得する WeatherViewModel
を作成していきます。
WeatherViewModel
は ViewModels
フォルダを新規作成してその中に入れておきます。
先にコード全文を共有すると、以下のようになります。
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()
}
}
細かく見ていきましょう。
以下の部分では、ObservableObject
として定義し、@Published var weatherData: Weather?
とすることで、どのクラスからでも weatherData
にアクセスすることができるようになっています。
class WeatherViewModel: ObservableObject {
@Published var weatherData: Weather?
以下の部分では、先述のURLを設定しています。
URLが正しいフォーマットになっているかどうかを調べて、正しくなければ、エラーをプリントするようになっています。
今回は東京の天気のみなので、緯度、経度、タイムゾーンを引数として受け取る必要はなく、単にベタ打ちすれば良いのですが、今後の実装を考えてこのようにしておきます。
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
}
以下では、設定した URL をもとに API を叩いてデータを取得し、レスポンスを Weather
型として受け取っています。
今回はキャッシュ等は使用しないため、URLSession.shared
を使用しています。
URLSession.shared
の一連の処理はただ書いただけだと実行されないようなので、最後に resume()
を書くことで実行する必要があります。
URLSession.shared.dataTask(with: url) { data, _, error in
DispatchQueue.main.async {
if let data = data {
do {
// Weather 型にデコード
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() // 実行
ContentView
今までのステップで、Open-Meteo API を叩いて、レスポンスを正しいデータ型に変更するまでは完了しました。最後に取得したデータを ContentView で表示させてみましょう。
ContentView のコード全文は以下のようになります。
import SwiftUI
struct ContentView: View {
@ObservedObject var weatherViewModel = WeatherViewModel()
let tokyoLat = 35.6895
let tokyoLon = 139.6917
let tokyoTimeZone = "Asia%2FTokyo"
var body: some View {
VStack {
Text("東京の天気")
.font(.title)
if (weatherViewModel.weatherData != nil) {
HStack {
Text("気温")
Text("\(Int(weatherViewModel.weatherData!.current.temperature2M))℃")
}
HStack {
Text("降水量")
Text("\(Int(weatherViewModel.weatherData!.current.rain))")
}
} else {
Text("天気を取得...")
}
}
.onAppear {
weatherViewModel.fetchWeatherData(latitude: String(tokyoLat), longitude: String(tokyoLon), timeZone: tokyoTimeZone)
}
}
}
以下のコードでは先程作成した WeatherViewModel
を読み込んでいます。
@ObservedObject var weatherViewModel = WeatherViewModel()
以下のコードでは、CotnentView が表示される時に、fetchWeatherData
を実行して、APIからデータを取ってきています。
そのデータを weatherData
から読み取ることで、View側で天気の情報を読み取ることができるようになっています。
.onAppear {
weatherViewModel.fetchWeatherData(latitude: String(tokyoLat), longitude: String(tokyoLon), timeZone: tokyoTimeZone)
}
実行すると以下のように東京の現在の気温と降水量が取得できていることがわかります。
今回のコード全文
import SwiftUI
@main
struct WeatherApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
import SwiftUI
struct ContentView: View {
@ObservedObject var weatherViewModel = WeatherViewModel()
let tokyoLat = 35.6895
let tokyoLon = 139.6917
let tokyoTimeZone = "Asia%2FTokyo"
var body: some View {
VStack {
Text("東京の天気")
.font(.title)
if (weatherViewModel.weatherData != nil) {
HStack {
Text("気温")
Text("\(Int(weatherViewModel.weatherData!.current.temperature2M))℃")
}
HStack {
Text("降水量")
Text("\(Int(weatherViewModel.weatherData!.current.rain))")
}
} else {
Text("天気を取得...")
}
}
.onAppear {
weatherViewModel.fetchWeatherData(latitude: String(tokyoLat), longitude: String(tokyoLon), timeZone: tokyoTimeZone)
}
}
}
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"
}
}
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()
}
}
まとめ
最後まで読んでいただいてありがとうございました。
今回は Open-Meteo API から天気情報を取得し、表示するまでを実装しました。
API を使えるだけで、実装できるアプリの機能の幅も広がりますし、プロジェクトでフロントエンドとバックエンドが分かれている場合にAPIを実装することもあるかと思うので、そのような場面でも役に立つかと思います。
間違い等あれば指摘していただければ嬉しいです。
参考
Discussion