I haven't yet taken the time to update this page, which describes the earliest version of the app in 2022.
Since then I have:
Visit https://github.com/harr1424/Orange-Weather to see what's new, or download the app on the App Store.
Orange_WeatherApp
.
@main
to identify it as the main view.
MainWeatherView
once the
app
is
initialized.
import SwiftUI
@main
struct Orange_WeatherApp: App {
var body: some Scene {
WindowGroup {
MainWeatherView()
}
}
}
MainWeatherView
struct is the initial point of entry that a user has
with
the
app.
As
such it has been designed to respond immediately if a user is not connected to the
internet
or
if
they have not granted location permissions to the app. In order to perform these checks,
methods
have been defined in two classes Networking
and NetworkStatus
.
Networking
will be examined in detail below, but contains members and
methods
which
are
related to device location, permissions, and requesting information from the OpenWeather
API.
NetworkStatus
contains members and methods solely related to the
connectivity
status
of the device.
@StateObject
. SwiftUI allows structs
and
classes
to publish members which can then be observed by other structs or classes. If a struct
or
class
observes an object that it instantiates, the @StateObject
annotation should
be
used. If
an
observable object is then later passed into another class, that referencing class should
annotate
the
observed object with the ObservedObject
annotation.
CLLocationManager
API that developers can use to
interface
with
a
device location. This API sends and receives location information using the
CLLocationDegrees
data type.
update()
helper method is used to perform API requests anytime the UI
should be
updated
to show the most recent weather data.
View
is a protocol. Structs and classes are said to
conform to
protocols. (Protocols are analogous to interfaces in Java and Kotlin).
In order for a given view to conform to the View
protocol, it must
implement a
variable
named body
which describes the view.
var body: some View { ...
may seem confusing at first. This
declaration
defines the body
attribute necessary for MainWeatherView
to
conform to
the
body
is a variable and not a class, struct, or
enumeration, the syntax var body: some View
means that the variable
body
is some type of view. It could be a UITextView
, a
UITableView
,
or
any other type of view, but declaring its type as a View
is essential for
the
compiler
operations
that allow SwiftUI to update UI elements so quickly.
NavigationView
which
has several benefits:
import SwiftUI
import CoreLocation
/* The initial View shown in the application. From here, a user
is able to see current weather information as well as navigate to other
views of the app. If a user is not connected to the internet, or
has not granted location permissions to this app, the view will
display an error message. */
struct MainWeatherView: View {
/* State objects are used here as opposed to ObservedObjetcs since
these objects are being instantiated in this class*/
@StateObject var network = Networking() // object containing location and weather information
@StateObject var networkConn = NetworkStatus() // object containing network connectivity status
var lat: CLLocationDegrees {
return self.network.lastLocation?.coordinate.latitude ?? 0
}
var lon: CLLocationDegrees {
return self.network.lastLocation?.coordinate.longitude ?? 0
}
var locationString: String {
return self.network.locationString?.name ?? "Orange Weather"
}
func update(lat: CLLocationDegrees, lon: CLLocationDegrees) {
self.network.getLocationString(lat, lon)
self.network.getMainWeather(lat, lon)
self.network.getAQI(lat, lon)
}
var body: some View {
// If a user is not connected to the internet
if !networkConn.isConnected {
Image(systemName: "antenna.radiowaves.left.and.right")
.resizable()
.aspectRatio( contentMode: .fit)
.scaleEffect(0.75)
.foregroundColor(.blue)
Text("""
You currently are not connected to the internet. Please connect to Wi-Fi or cellular data in order to use this app.
""")
.fontWeight(.bold)
.font(.system(size: 24))
.foregroundColor(.blue)
.multilineTextAlignment(.center)
.padding()
}
// If a user has not granted location permissions while the app is in use
else if !network.permissions {
Image(systemName: "tornado")
.resizable()
.aspectRatio( contentMode: .fit)
.scaleEffect(0.75)
.foregroundColor(.blue)
Text("""
This app requires permission to access your location while the app is in use. It will not work otherwise. Your location
data is never shared with nor stored by the developer. Please grant location permissions to this app from the settings menu.
""")
.fontWeight(.bold)
.font(.system(size: 24))
.foregroundColor(.blue)
.multilineTextAlignment(.center)
.padding()
} else { // Display the intended view
NavigationView {
VStack {
if let response = network.weatherResponse {
Image(systemName: WeatherModel.getConditionName(weatherID: (response.current.weather[0].id)))
.resizable()
.aspectRatio( contentMode: .fit)
.scaleEffect(0.75)
.foregroundColor(WeatherModel.getIconColor(weatherID: response.current.weather[0].id))
}
HStack {
VStack{
Text("Temperature")
.fontWeight(.bold)
.font(.system(size: 24))
Text("Wind")
.fontWeight(.bold)
.font(.system(size: 24))
Text("Humidity")
.fontWeight(.bold)
.font(.system(size: 24))
Text("Dew Point")
.fontWeight(.bold)
.font(.system(size: 24))
Text("UV Index")
.fontWeight(.bold)
.font(.system(size: 24))
Text("Air Quality")
.fontWeight(.bold)
.font(.system(size: 24))
Spacer()
}
VStack {
if let response = network.weatherResponse {
Text("\(response.current.temp, specifier: "%.0f")°F")
.fontWeight(.bold)
.font(.system(size: 24))
Text("\(response.current.wind_speed, specifier: "%.0f") mph from \(WeatherModel.getWindDirection(degree: response.current.wind_speed))")
.fontWeight(.bold)
.font(.system(size: 24))
Text("\(response.current.humidity, specifier: "%.0f") %")
.fontWeight(.bold)
.font(.system(size: 24))
Text("\(response.current.dew_point, specifier: "%.2f") °F")
.fontWeight(.bold)
.font(.system(size: 24))
Text("\(response.current.uvi, specifier: "%.0f") \(WeatherModel.getUvIndexCategory(uvIndex: response.current.uvi))")
.fontWeight(.bold)
.font(.system(size: 24))
}
if let response = network.currAQI {
Text(WeatherModel.getAQIstring(aqi: response.list[0].main.aqi))
.fontWeight(.bold)
.font(.system(size: 24))
}
Spacer()
}
}
HStack {
NavigationLink(destination: HourlyWeatherView(hourly: network.weatherResponse?.hourly)) {
ButtonView(text: "Hourly")
}
NavigationLink(destination: WeatherAlertView(alerts: network.weatherResponse?.alerts)) {
ButtonView(text: "Alerts")
}
NavigationLink(destination: DailyWeatherView(daily: network.weatherResponse?.daily)) {
ButtonView(text: "Daily")
}
}
}
.navigationTitle(locationString)
.environmentObject(network)
.onAppear {
/* Depending upon network latency, the weather information, location string, and air quality index
may not have been retrieved and/or decoded by the time the view appears. If lat and lon are still nil,
update the UI*/
if lat == 0 && lon == 0 {
DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: {
self.update(lat: lat, lon: lon)
})
}
self.update(lat: lat, lon: lon)
}
}
}
}
}
Orange Weather's MainWeatherView
displays current weather information and also
links to
additional views describing
the hourly forecast, daily forecast, and any currently-issued weather alerts. To implement these
secondary
views,
it is first necessary to define them as a struct
specifying what information is
shown
in
each
view. This process is shown below for an HourlyView
(below left). Once
HourlyView
has been defined, it is then possible to create a list of
HourlyView
views
useful for displaying many forecasted hourly conditions, each displayed in their own view. The
process
of
creating this List
view is shown below on the right.
/* A View used to display weather information specific to a given hour.
Multiple HourlyView Views will be displayed in a list. */
struct HourlyView: View {
let calendar = Calendar.current
var hourly: Hourly
var body: some View {
HStack {
VStack {
Spacer()
Text("\(WeatherModel.getWeekDay(day: Int(calendar.component(.weekday, from: NSDate(timeIntervalSince1970: TimeInterval(hourly.dt)) as Date))))")
.fontWeight(.bold)
Text("\(WeatherModel.getTimeAs12hr(hour: Int(calendar.component(.hour, from: NSDate(timeIntervalSince1970: TimeInterval(hourly.dt)) as Date))))")
.fontWeight(.bold)
Spacer()
Text("\(hourly.temp, specifier: "%.0f")°F")
.fontWeight(.bold)
Spacer()
}
Image(systemName: WeatherModel.getConditionName(weatherID: hourly.weather[0].id))
.resizable()
.aspectRatio( contentMode: .fit)
.scaleEffect(0.75)
.foregroundColor(WeatherModel.getIconColor(weatherID: hourly.weather[0].id))
VStack {
Spacer()
Text("Wind \(hourly.wind_speed, specifier: "%.0f") mph \(WeatherModel.getWindDirection(degree: hourly.wind_deg))")
Spacer()
Text("\(hourly.pop * 100, specifier: "%.0f")% chance of \(WeatherModel.getRainorSnow(temp: hourly.temp))")
Spacer()
}
}
}
}
/* A View describing the hourly forecast for a given location. */
struct HourlyWeatherView: View {
var hourly: [Hourly]?
var body: some View {
if let currHourly = hourly {
List(currHourly) { forecast in
HourlyView(hourly: forecast)
}
.navigationTitle("Hourly")
} else {
Image(systemName: "questionmark")
.resizable()
.aspectRatio( contentMode: .fit)
.scaleEffect(0.75)
.foregroundColor(.blue)
Text("Something went wrong... ")
.fontWeight(.bold)
.font(.system(size: 24))
.foregroundColor(.blue)
.multilineTextAlignment(.center)
Spacer()
.navigationTitle("Hourly")
}
}
}
This same process of defining a base view and then creating a List
consisting of
base
views
has
been repeated for both AlertView
and DailyView
views. Full source code
can
be
found on the project's GitHub
repository.
The most difficult part of creating Orange Weather involved retrieving a device's current location and using this location in subsequent API calls. The class examined below contains logic to accomplish this:
@Published
annotation.
Note
that
these objects have been defined in a separate file, which will be examined later. Most
importantly,
when
defining these objects as structs, they must conform to the
Observable Object
protocol in order to be published and observed.
init()
method is responsible for setting the location
manager
delegate
property to be itself. The
delegate
pattern
is very common in iOS development, and many of Apple's APIs implement this pattern.
Effectively,
by setting the current class as the location manager delegate, we are allowing this
class to
respond
to
permission changes, handle errors, respond to location events, and in general, control
the
functioning
of the CLLocationManager
API instance. Also in init()
, the
desired
location
is set to an area reasonable for obtaining accurate weather data, adequate permissions
are
requested
of the user, and the instance is instructed to begin updating the user's location.
.notDetermined
. Hopefully, the user grants permission to the app to access
device
location
while the app is in use, but this will take a few moments. Once a user has decided
whether
or
not to
grant permissions, the didChangeAuthorizations
method will be called, which
will in
turn
update the variables in Networking
responsible for tracking current
permission
status.
CLLocationManager
updates the
lastLocation
property
the
method didUpdateLocations
will be called. Since our Networking
class
is set
as the delegate for this instance, we are able to define what should happen once
location
has
been
updated.
Here, we have instructed the app to perform API calls that will update the published
properties
that are being observed in the UI. Once this completes, we instruct
CLLocationManager
to
stop updating the device location. If we did not do this, the app would continuously
update
the
device
location every few seconds and perform three API calls each time, and so we would
quickly
exceed
our limited available API calls (OpenWeather limits to 60 API calls per minute at their
free
tier).
getMainWeather
loads data
representing
current weather conditions into a published object, getLocationString
loads
a
string representation of the user's current location into a published object, and
getAQI
does the same for the current Air Quality Index.
URLSession
in order to send a request and process a response. This response
will
consist
of JSON data, which will need to be decoded, into a series of struct
representations of
that
data. Assuming all goes well, the response will be decoded and the data will be used to
update
the UI. Note that this last step is performed on the main thread
DispatchQueue.main.async
ensuring that once this data is available, it will
be
updated on the main thread, which is the thread devoted to UI updates.
getLocationString
and getAQI
.
class Networking: NSObject, ObservableObject, CLLocationManagerDelegate {
@Published var weatherResponse: WeatherResponse?
@Published var locationString: LocationResponse?
@Published var currAQI: AQIresponse?
private let locationManager = CLLocationManager()
@Published var locationStatus: CLAuthorizationStatus?
@Published var lastLocation: CLLocation?
override init() {
super.init()
locationManager.delegate = self
locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
locationManager.requestWhenInUseAuthorization()
locationManager.startUpdatingLocation()
}
var statusString: String {
guard let status = locationStatus else {
return "unknown"
}
switch status {
case .notDetermined: return "notDetermined"
case .authorizedWhenInUse: return "authorizedWhenInUse"
case .authorizedAlways: return "authorizedAlways"
case .restricted: return "restricted"
case .denied: return "denied"
default: return "unknown"
}
}
var permissions: Bool {
switch statusString {
case "notDetermined": return false
case "authorizedWhenInUse": return true
case "authorizedAlways": return true
case "restricted": return false
case "denied": return false
default: return false
}
}
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
locationStatus = status
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else { return }
lastLocation = location
getMainWeather(self.lastLocation?.coordinate.latitude ?? 0, self.lastLocation?.coordinate.longitude ?? 0)
getLocationString(self.lastLocation?.coordinate.latitude ?? 0, self.lastLocation?.coordinate.longitude ?? 0)
getAQI(self.lastLocation?.coordinate.latitude ?? 0, self.lastLocation?.coordinate.longitude ?? 0)
locationManager.stopUpdatingLocation()
}
func getMainWeather(_ lat: CLLocationDegrees, _ lon: CLLocationDegrees) {
if let loc = URL(string: "https://api.openweathermap.org/data/2.5/onecall?appid=REDACTED&exclude=minutely&units=imperial&lat=\(lat)&lon=\(lon)") {
let session = URLSession(configuration: .default)
let task = session.dataTask(with: loc) { data, response, error in
if error == nil {
let decoder = JSONDecoder()
if let safeData = data {
do {
let results = try decoder.decode(WeatherResponse.self, from: safeData)
DispatchQueue.main.async {
self.weatherResponse = results
}
print("weatherResponse was successfully updated for \(lat) \(lon)")
} catch {
print(error)
}
}
} else {
print(error!)
}
}
task.resume()
}
}
func getLocationString(_ lat: CLLocationDegrees, _ lon: CLLocationDegrees) {
if let loc = URL(string: "https://api.openweathermap.org/data/2.5/weather?appid=REDACTED&exclude=minutely&units=imperial&lat=\(lat)&lon=\(lon)") {
let session = URLSession(configuration: .default)
let task = session.dataTask(with: loc) { data, response, error in
if error == nil {
let decoder = JSONDecoder()
if let safeData = data {
do {
let results = try decoder.decode(LocationResponse.self, from: safeData)
DispatchQueue.main.async {
self.locationString = results
}
print("Location was successfully updated for \(lat) \(lon)")
} catch {
print(error)
}
}
} else {
print(error!)
}
}
task.resume()
}
}
func getAQI(_ lat: CLLocationDegrees, _ lon: CLLocationDegrees) {
if let loc = URL(string: "https://api.openweathermap.org/data/2.5/air_pollution?appid=REDACTED&exclude=minutely&lat=\(lat)&lon=\(lon)") {
let session = URLSession(configuration: .default)
let task = session.dataTask(with: loc) { data, response, error in
if error == nil {
let decoder = JSONDecoder()
if let safeData = data {
do {
let results = try decoder.decode(AQIresponse.self, from: safeData)
DispatchQueue.main.async {
self.currAQI = results
}
print("AQI was successfully updated for \(lat) \(lon)")
} catch {
print(error)
}
}
} else {
print(error!)
}
}
task.resume()
}
}
}
I was delighted with the simplicity involved in decoding the JSON response in Swift. This
process is
considerably
more straightforward than what I was accustomed to in Android, which required the use of
third-party
libraries
such as RetroFit and Moshi. In Swift, it is sufficient to examine the API response and construct
struct
objects containing the properties you wish to decode. This is something I
would
have
definitley taken for granted if I had not formerly used RetroFit and Moshi, which require every
JSON
response field
to be declared inside some object. The below code demonstrates what the response returned by the
API
endpoint
called in getMainWeather
will look like. Note that for the sake of readability,
only
the
elements contained in the current
array have been copied. hourly
and
daily
are arrays containing a large number of similarly-structured elements.
"lat": 46.8721,
"lon": -113.994,
"timezone": "America/Denver",
"timezone_offset": -21600,
"current": {
"dt": 1655513488,
"sunrise": 1655466057,
"sunset": 1655523177,
"temp": 75.79,
"feels_like": 74.95,
"pressure": 1002,
"humidity": 40,
"dew_point": 49.78,
"uvi": 1.18,
"clouds": 0,
"visibility": 10000,
"wind_speed": 17.27,
"wind_deg": 240,
"wind_gust": 25.32,
"weather": [
{
"id": 800,
"main": "Clear",
"description": "clear sky",
"icon": "01d"
}
]
},
"hourly": [...],
"daily": [...],
}
getMainWeather()
that the JSONDecoder
expects
a parameter WeatherResponse.self
. This refers to a
WeatherResponse
struct
that the decoder will use to map the JSON data to. The self
keyword is
necessary
because the WeatherResponse
struct is referenced from within a closure.
More
about
this
can be found here.
current
array as shown above. Our WeatherResponse
struct has now declared
properties
corresponding
to the JSON response elements we are interested in, including current
.
Next, we
must
create structs to receive the elements nested inside of the current
array.
struct WeatherResponse: Decodable{
let current: Current
let hourly: [Hourly]
let daily: [Daily]
let alerts: [Alert]?
}
Current
struct has declared attributes for each field we are interested
in
using
from the JSON response. Note that weather
is of type Weather
array,
and
so we need to define one last Weather
struct to describe an individual
Weather
element. The only nested element we are interested in is id
, an integer
that
represents
a weather condition (i.e. rain, clouds, sun, wind, etc.) This element will be used to
display
an icon that best represents current or forecasted weather conditions.
Decodable
protocol. This makes things considerably easier,
as
the
protocol handles the process of decoding JSON response data into standard library types,
such as
strings, integers, and doubles automatically.
struct
attributes can be ultimately mapped
to
Standard Library types, this is all that is required. When working with custom types, it
is
possible to define the decoding process using
Coding
Keys.
struct Current: Decodable {
let temp: Double
let humidity: Double
let uvi: Double
let wind_speed: Double
let wind_deg: Double
let dew_point: Double
let weather: [Weather]
}
struct Weather: Decodable{
let id: Int
}
A similar procedure has been followed to define structs that will receive the other elements of the JSON response used in Orange Weather. We are almost done, lastly, we will examine some helper methods that enhance the user experience of this app.
MainWeatherView
. This is
something
common in many weather apps.
id
that we examined
above.
/* Given an integer representing a weather condition,
will return a String corresponding to an SF Symbol to be displayed. */
static func getConditionName(weatherID: Int) -> String {
switch weatherID {
case 200..<300:
return "cloud.bolt.rain.fill"
case 300..<400:
return "cloud.drizzle.fill"
case 500..<600:
return "cloud.rain.fill"
case 600..<700:
return "cloud.snow.fill"
case 701:
return "cloud.fog.fill"
case 711:
return "smoke.fill"
case 721:
return "sun.haze.fill"
case 731:
return "sun.dust.fill"
case 741:
return "cloud.fog.fill"
case 751:
return "sun.dust.fill"
case 761:
return "sun.dust.fill"
case 762:
return "sun.dust.fill"
case 771:
return "wind"
case 781:
return "tornado"
case 800...802:
return "sun.max.fill"
case 803...804:
return "cloud.sun.fill"
default:
return "questionmark"
}
}
Current Air Quality: 5
when, in fact, the air quality they are experiencing is among the
worst
in the entire world. We can define helper methods that will allow us to programmatically
give
the
user context about the current air quality.
getAQIstring
will return a string representation of the current air
quality,
and
this
string representation will be used within a Text view to provide easy-to-understand
information
about the air quality.
MainWeatherView
, changing text color to orange, red, and purple as the air
quality
progressively worsens.
/* Given an Integer representing the current Air Quality Index, will return
a String description of the current index. */
static func getAQIstring(aqi: Int) -> String {
switch aqi {
case 1: return "Good"
case 2: return "Fair"
case 3: return "Moderate"
case 4: return "Poor"
case 5: return "Very Poor"
default: return "Not Available"
}
}
/* Given an Integer representing the current Air Quality Index, will return
a color representation of the current index. */
static func getAQIColor(aqi: Int) -> Color {
switch aqi {
case 1: return .primary
case 2: return .primary
case 3: return .orange
case 4: return .red
case 5: return .purple
default: return .primary
}
}
That's it! This project allowed me to learn so much about Swift and Apple iOS APIs. If you would like to explore Orange Weather, you may find it on the Apple App Store at the link below. You may also view the project source code on GitHub