Accessing Live Weather Data
There are a variety of weather services you can use, but for this app, we will use the Dark Sky service. Sign up for a developer account on theDark Sky register page.
Once you have signed up, you will be issued an API key. We will need this to use the API. Currently, the API allows up to 1,000 calls per day before you have to pay for usage.
The call to the API is actual quite simple:
https://api.darksky.net/forecast/APIKEY/LATITUDE,LONGITUDE
If we just replaced our local data call in the_weather-data.ts_file with this URL (replacing the APIKEY with your API key, and also hardcoding a location), you would find that it will not work. Opening the JavaScript console, you would see an error like this:
XMLHttpRequest
cannot load https://api.darksky.net/forecast/APIKEY/LATITUDE,LONGITUDE.
No 'Access-Control-Allow-Origin' header is present on the requested resource.
Origin 'http://localhost:8100' is therefore not allowed access.
This error happens due to the browser’s security policies, which are essentially a way to block access to data from other domains. It is also referred to as CORS (Cross-Origin Resource Sharing). Typically, solving this issue during development requires setting up a proxy server or some other workaround. Thankfully, the Ionic CLI has a built-in workaround.
Inionic.config.json(which can be found at the root level of our app), we can define a set of proxies forionic serve
to use. Just add in aproxies
property and define the path and the proxyUrl. In our case it, will look like this:
{
"name": "Ionic2Weather",
"app_id": "",
"type": "ionic-angular",
"proxies": [
{
"path": "/api/forecast",
"proxyUrl": "https://api.darksky.net/forecast/APIKEY"
}
]
}
Replacing the APIKEY in the proxyURL with your actual API key.
Save this file, making sure to restart$ ionic serve
for these changes to take effect.
Returning back to the_weather-service.ts_file, we can update the http.get call to be:
this.http.get('/api/forecast/43.0742365,-89.381011899')...
Our application will be able to now properly call the Dark Sky API, and our live weather data will be returned to our app.
Connecting the Geolocation and Weather Providers
Currently our latitude and longitude values are hardcoded into the Dark Sky request. Let’s address this issue.
Obviously, we need to know our current position. We have that code already in place with theGeolocation
function. Since we are using the Ionic Native wrapper to the Cordova plug-in, this is already sent up as a Promise. One of the advantages of using Promises is the ability to chain them together, which is exactly what we need to do here. Once we have our location, then we can call Dark Sky and get our weather data.
To chain Promises together, you just continue on using the.then
function:
geolocation.getCurrentPosition().then(pos =
>
{
console.log('lat: ' + pos.coords.latitude + ', lon: ' + pos.coords.longitude);
this.currentLoc.lat = pos.coords.latitude;
this.currentLoc.lon = pos.coords.longitude;
this.currentLoc.timestamp = pos.timestamp;
return this.currentLoc;
}).then(currentLoc =
>
{
weatherService.getWeather(currentLoc).then(theResult =
>
{
this.theWeather = theResult;
this.currentData = this.theWeather.currently;
this.day1 = this.theWeather.daily.data[0];
this.day2 = this.theWeather.daily.data[1];
this.day3 = this.theWeather.daily.data[2];
loader.dismiss();
});
});
Besides adding.then
, we have to add the returnthis.currentLoc
within the Geolocation Promise. By adding a return, it enables us to pass this data along the Promise chain.
DEMO CODE WARNING
We are being a bit sloppy here and not accounting for any errors. With any network calls to a remote system, you should always expect failure and code for that case.
This is a great example of how using Promises can make work with asynchronous processes so much easier.
Another minor tweak to the app is to remove the duration value in our loading dialog:
let loader = this.loadingCtrl.create({
content: "Loading weather data..."
});
loader.present();
We will let the loading dialog now stay up until we finally get our weather data. Once we do, we can simply callloading.dismiss()
and remove the loading dialog.
Now we need to update our_weather-service.ts_to support dynamic locations.
First, import our custom location class:
import { CurrentLoc } from '../../interfaces/current-loc';
Next, change theload
function to accept our current location:
load(currentLoc:CurrentLoc) {
Then modify thehttp.get
call to reference this information:
this.http.get('/api/forecast/'+currentLoc.lat+','+currentLoc.lon)
Finally, we also need to adjust thegetWeather
method as well to support using a location:
getWeather(currentLoc:CurrentLoc) {
this.data = null;
return this.load(currentLoc).then(data =
>
{
return data;
});
}
We also clear the weather data that was previously retrieved by the service before we load new data. Here is the revised_weather-service.ts_file:
import { Injectable } from '@angular/core';
import { Http } from '@angular/http';
import 'rxjs/add/operator/map';
import { CurrentLoc } from '../interfaces/current-loc'
@Injectable()
export class WeatherService {
data: any = null;
constructor(public http: Http) {
console.log('Hello WeatherService Provider');
}
load(currentLoc:CurrentLoc) {
if (this.data) {
return Promise.resolve(this.data);
}
return new Promise(resolve =
>
{
this.http.get('/api/forecast/'+currentLoc.lat+','+currentLoc.lon)
.map(res =
>
res.json())
.subscribe(data =
>
{
this.data = data;
resolve(this.data);
});
});
}
getWeather(currentLoc:CurrentLoc) {
this.data = null;
return this.load(currentLoc).then(data =
>
{
return data;
});
}
}
Save the files, reload the application, and live local weather information will be shown on the page.
Getting Other Locations’ Weather
It is nice to know the weather where you are, but you might also want to know the weather in other parts of the world. We will use the side menu to switch to different cities, as inFigure 9-4.
Figure 9-4.Ionic weather app’s side menu
The first set of changes we are going to make is to thepages
array in the_app.component.ts_file. First, let’s import ourCurrentLoc
class:
import { CurrentLoc } from '../interfaces/current-loc';
Next, we need to include the location’s latitude and longitude data that we can reference to look up the weather. Since neither the Edit Locations nor the Current Locations will have predefined locations, this new property will be declared as optional:
pages: Array
<
{title: string, component: any, icon: string, loc?:CurrentLoc}
>
;
Next, we will fill this array with some locations:
this.pages = [
{ title: 'Edit Locations', component: LocationsPage, icon: 'create' },
{ title: 'Current Location', component: WeatherPage, icon: 'pin' },
{ title: 'Cape Canaveral, FL', component: WeatherPage, icon: 'pin', ↵
loc: {lat:28.3922, lon:-80.6077} },
{ title: 'San Francisco, CA', component: WeatherPage, icon: 'pin', ↵
loc: {lat:37.7749, lon:-122.4194} },
{ title: 'Vancouver, BC', component: WeatherPage, icon: 'pin', ↵
loc: {lat:49.2827, lon:-123.1207} },
{ title: 'Madison, WI', component: WeatherPage, icon: 'pin', ↵
loc: {lat:43.0742365, lon:-89.381011899} }
];
Now, when we select one of the four cities, we now know its latitude and longitude. But we need to pass this data along to our weather page. In Ionic 1, we would have used$stateParams
to do this; but in Ionic 2, we can just pass an object as a parameter to theNavController
. So, ouropenPage
function will now pass this data (assuming it is there):
openPage(page) {
// Reset the content nav to have just this page
// we wouldn't want the back button to show in this scenario
if (page.hasOwnProperty('loc') ) {
this.nav.setRoot(page.component, {geoloc: page.loc});
} else {
this.nav.setRoot(page.component);
}
}
Go ahead and save this file, and openweather.ts. Retrieving the data is actually just a simpleget()
call. We will assign the data to a variable namedloc
. Add this code after we display the loading dialog:
let loc = this.navParams.get('geoloc');
If this variable is undefined, we will make the call to theGeolocation
method like before. But, if this value is defined, then we will use that data to call the weather service with it:
if (loc === undefined) {
geolocation.getCurrentPosition().then(pos =
>
{
console.log('lat:' + pos.coords.latitude + ', lon:' + pos.coords.longitude);
this.currentLoc.lat = pos.coords.latitude;
this.currentLoc.lon = pos.coords.longitude;
this.currentLoc.timestamp = pos.timestamp;
return this.currentLoc;
}).then(currentLoc =
>
{
weatherService.getWeather(currentLoc).then(theResult =
>
{
this.theWeather = theResult;
this.currentData = this.theWeather.currently;
this.day1 = this.theWeather.daily.data[0];
this.day2 = this.theWeather.daily.data[1];
this.day3 = this.theWeather.daily.data[2];
loader.dismiss();
});
});
} else {
this.currentLoc = loc;
weatherService.getWeather(this.currentLoc).then(theResult =
>
{
this.theWeather = theResult;
this.currentData = this.theWeather.currently;
this.day1 = this.theWeather.daily.data[0];
this.day2 = this.theWeather.daily.data[1];
this.day3 = this.theWeather.daily.data[2];
loader.dismiss();
});
}
If we have a location, we will first assign it to the currentLoc variable, then call ourgetWeather
function.
Save this file and test it out. Hopefully, the weather is different in each of the locations.
However, our page title is not updating after we switch locations. This is actually another simple fix. First, we need to pass the location’s name along with its location. Inapp.component.ts, change:
this.nav.setRoot(page.component, {geoloc: page.loc});
to:
this.nav.setRoot(page.component, {geoloc: page.loc, title: page.title});
Inweather.ts, we need to make two changes. First, we need a variable to store our page title in so the template can reference it. In the class constructor, we will add this:
pageTitle: string = 'Current Location';
Then, within our code block that gets the weather for the other locations, we can get the other NavParam and assign it to thepageTitle
:
this.pageTitle = this.navParams.get('title');
The last minor change is to the template itself. We need to update the<ion-title>
tag to reference thepageTitle
variable:
<
ion-title
>
{{pageTitle}}
<
/ion-title
>
And with that, our page title should now display our current location’s name.
Pull to Refresh: Part 2
You might have been wondering why we bothered to set the currentLoc to the location that was passed in. We can add it directly into ourgetWeather
function. Remember that pull to refresh component we added? Yeah, that one. We actually never had it do anything except close after two seconds. Let’s update this code to actually get new weather. All we have to do is replace thesetTimeout
, so thedoRefresh
function becomes:
doRefresh(refresher) {
this.weatherService.getWeather(this.currentLoc).then(theResult =
>
{
this.theWeather = theResult;
this.currentData = this.theWeather.currently;
this.day1 = this.theWeather.daily.data[0];
this.day2 = this.theWeather.daily.data[1];
this.day3 = this.theWeather.daily.data[2];
refresher.complete();
});
}
API LIMITS
Within thedoRefresh
would be a great place to check that timestamp property to prevent API abuse.
Unfortunately, I doubt you will see any changes to the weather data, as weather usually does not change that quickly. Let’s turn our attention to the Edit Location screen.
Editing the Locations
This screen will be where we can add a new city to our list, or remove an existing one. Here is what our final screen will look likeFigure 9-5.
Figure 9-5.The Add City dialog
Open the_locations.html_file and let’s add in our add city button after the<ion-content>
. You should remove thepadding
attribute as well:
<
ion-content
>
<
button ion-button icon-left clear color="dark" item-left
(click)="addLocation()"
>
↵
<
ion-icon name="add"
>
<
/ion-icon
>
Add City
<
/button
>
<
/ion-content
>
For this button, we are applying the clear and dark styles to it. The clear attribute will remove any border or background from the button, while the dark attribute will color the icon with the dark color. We will have the click event call a function namedaddLocation
.
After the<button>
tag, we are going to add in our<ion-list>
that will display the list of our saved locations:
<
ion-list
>
<
ion-list-header
>
My Locations
<
/ion-list-header
>
<
ion-item *ngFor="let loc of locs"
>
<
button ion-button icon-left clear color="dark"
(click)="deleteLocation(loc)"
>
<
ion-icon name="trash"
>
<
/ion-icon
>
<
/button
>
{{loc.title}}
<
/ion-item
>
<
/ion-list
>
We are using the<ion-list-header>
to act as our list header rather than just a regular header tag. Hopefully, the<ion-item>
tag looks a bit familiar to you. It is just going to iterate over an array of locs and render out each location. Next, we set up a button that will allow us to delete a location. Again, we will use the clear and dark attributes for the button styling. The click event will call a function nameddeleteLocation
that we have yet to write. We will wrap up the code block by binding the text to the title value with{{loc.title}}
.
With the HTML in place, we can focus on the changes we need to make in our code. We will start with_locations.ts_and add in the elements needed to get the template to work at a basic level.
We will again import ourCurrentLoc
class:
import { CurrentLoc } from '../../interfaces/current-loc';
Within the class definition, we need to create ourlocs
array. This array is actually going to be the same content as ourpages
array does in the_app.component.ts_file:
export class LocationsPage {
locs: Array
<
{ title: string, component: any, icon: string,
loc?: CurrentLoc }
>
;
But whenever you find yourself repeating elements like this, you should always pause and consider if this is something that should be abstracted. In this case, our answer is going to be yes. Instead of listing out our array’s structure, we will move into the object to an interface and simply use it instead.
Create a new file named_weather-location.ts_inside the_interfaces_directory. Here we will define our WeatherLocation interface. Since theloc
property uses theCurrentLoc
interface, we need to make sure we import that module as well:
import { CurrentLoc } from './current-loc';
export interface WeatherLocation {
title: string;
component: any;
icon: string;
loc?: CurrentLoc;
}
The actual interface will define the location’s title, its page component, its icon, and an optional element of aCurrentLoc
type.
With this interface created, switch back to_locations.ts_and import that interface:
import { WeatherLocation } from '../../interfaces/weather-location';
Then, we can update thelocs
array to be a properly typed array:
locs: Array
<
WeatherLocation
>
;
Next, let’s add the two placeholder functions for the adding and deleting of locations after the constructor:
deleteLocation(loc) {
console.log('deleteLocation');
}
addLocation() {
console.log('addLocation');
}
Let’s work on populating ourlocs
array with our saved places. We could simply copy over the pages array that is used in the side menu, and our template should render it out just fine. But any changes we make to ourlocs
array will not be reflected in thepages
array. We need to move this data into something that both components can reference.
For this, we will create a new provider namedLocationsService
. We will again make use the generate command in the Ionic CLI:
$ ionic g provider locationsService
Since this provider is not aimed at getting remote data, we are going to be replacing most of the generated template. The first thing we need to do is define our imports. We will needInjectable
from Angular, as well as ourWeatherLocation
interface andWeatherPage
component:
import { Injectable } from '@angular/core';
import { WeatherLocation } from '../../interfaces/weather-location';
import { WeatherPage } from '../../pages/weather/weather';
Now we can define our service, thelocations
Array (properly typed toWeatherLocation
), then initialize that array with our default locations:
export class LocationsServiceProvider {
locations: Array
<
WeatherLocation
>
;
constructor() {
this.locations = [
{ title: 'Cape Canaveral, FL', component: WeatherPage, icon: 'pin', ↵
loc: { lat: 28.3922, lon: -80.6077 } },
{ title: 'San Francisco, CA', component: WeatherPage, icon: 'pin', ↵
loc: { lat: 37.7749, lon: -122.4194 } },
{ title: 'Vancouver, BC', component: WeatherPage, icon: 'pin', ↵
loc: { lat: 49.2827, lon: -123.1207 } },
{ title: 'Madison, WI', component: WeatherPage, icon: 'pin', ↵
loc: { lat: 43.0742365, lon: -89.381011899 } }
];
}
We can wrap our service with the functions that we will need: one to get the locations, one to add to the locations, and one function to remove a location:
getLocations() {
return Promise.resolve(this.locations);
}
removeLocation(loc:WeatherLocation) {
let index = this.locations.indexOf(loc)
if (index != -1) {
this.locations.splice(index, 1);
}
}
addLocation(loc: WeatherLocation) {
this.locations.push(loc);
}
Returning back tolocations.ts, we can import this service into the component:
import { LocationsServiceProvider } from ↵
'../../providers/locations-service/locations-service';
We also need to add this module into our constructor, call that service’sgetLocations
method to get our default locations, and save the result into ourlocs
array:
constructor(public navCtrl: NavController,
public navParams: NavParams,
public locationsService: LocationsServiceProvider) {
locationsService.getLocations().then(res =
>
{
this.locs = res;
});
}
Saving the file, and navigating to the Edit Locations screen, you should now see our four default cities listed.
Deleting a City
Let’s implement the actualdeleteLocation
function in the_locations.ts_file. We already have the stub function in place in our component. All we need to do is simply call theremoveLocation
method on theLocationsService
:
deleteLocation(loc:WeatherLocation) {
this.locationsService.removeLocation(loc);
}
Adding a City
Adding a city is a bit more complex. If you recall, the Dark Sky weather service uses latitude and longitudes to look up the weather data. I doubt you will ask, “How is the weather in 32.715, –117.1625 ?” but rather, “How is the weather in San Diego?” To translate between a location and its corresponding latitude and longitude we need to use a geocoding service.
Using a Geocoding Service
For this app, we will use theGoogle Maps Geocoding API.
In order to use this API, we will need to be registered with Google as a developer and have an API key for our application. Go to theGoogle Maps API pageand follow the instructions to generate an API key for the geocoding API. Save this 40 character string, as we will need it shortly:
export class GeocodeServiceProvider {
data: any;
apikey:String =
'YOUR-API-KEY-HERE'
;
constructor(public http: Http) {
this.data = null;
}
getLatLong(address:string) {
if (this.data) {
// already loaded data
return Promise.resolve(this.data);
}
// don't have the data yet
return new Promise(resolve =
>
{
this.http.get('https://maps.googleapis.com/maps/api/geocode/↵
json?address='+encodeURIComponent(address)+'
&
key='+this.apikey)
.map(res =
>
res.json())
.subscribe(data =
>
{
if(data.status === "OK") {
resolve({name: data.results[0].formatted_address, location:{
latitude: data.results[0].geometry.location.lat,
longitude: data.results[0].geometry.location.lng
}});
} else {
console.log(data);
//reject
}
});
});
}
}
The first change is the new variable namedapikey
. You will need to set its value to the key you just generated.
The constructor just sets the data variable tonull
. The heart of this class is actually thegetLatLong
method. This method accepts one parameter namedaddress
.
The method will then make this request:
this.http.get('https://maps.googleapis.com/maps/api/geocode/json?↵
address='+encodeURIComponent(address)+'
&
key='+this.apikey)
Note that we have to use theencodeURIComponent
method to sanitize the address string before we can make the call to the Google Maps Geocoding API.
Once the data is returned from the service, we can check if a named place was located using thedata.status
value. Then we can traverse the JSON, get the needed data, and resolve the Promise.
Now that we have a way to turn San Diego into 32.715, –117.1625, we can return back to_locations.ts_and finish ouraddLocation
function.
We will need to import our new geocode service:
import { GeocodeServiceProvider } from ↵
'../../providers/geocode-service/geocode-service';
Also include it with the constructor:
constructor(public navCtrl: NavController,
public navParams: NavParams,
public locationsService: LocationsServiceProvider,
public geocodeService: GeocodeServiceProvider) {
Instead of using the Ionic Native Dialog plug-in to prompt our user to enter their point of interest, let’s use theAlertController
component. Again, we need to import it from the proper library:
import { NavController, NavParams, AlertController } from 'ionic-angular';
One of the difficulties in using the Ionic Native Alert is the degree to which you can extend it. The alert also requires you to test your application either in a simulator or on-device. By using the standard Ionic Alert component, we can keep developing directly in our browser. However, unlike the Ionic Native dialog, there is quite a bit more JavaScript that you need to write. We will need to update our constructor to include theAlertController
:
constructor(public navCtrl: NavController,
public navParams: NavParams,
public locationsService: LocationsServiceProvider,
public geocodeService: GeocodeServiceProvider,
public alertCtrl: AlertController) {
Here is the completedaddLocation
method with the Alert component used:
addLocation() {
let prompt = this.alertCtrl.create({
title: 'Add a City',
message: "Enter the city's name",
inputs: [
{
name: 'title',
placeholder: 'City name'
},
],
buttons: [
{
text: 'Cancel',
handler: data =
>
{
console.log('Cancel clicked');
}
},
{
text: 'Add',
handler: data =
>
{
console.log('Saved clicked');
}
}
]
});
prompt.present();
}
The key line of code not to forget is telling theAlertController
to present our alert withprompt.present()
.
Now, we are not doing anything with any city that you might enter, so let’s add that code. Replace this line:
console.log('Saved clicked');
with:
if (data.title != '') {
this.geocodeService.getLatLong(data.title).then(res =
>
{
let newLoc = { title: '', component: WeatherPage, icon: 'pin', ↵
loc: { lat: 0, lon: 0 } }
newLoc.title = res.name;
newLoc.loc.lat = res.location.latitude;
newLoc.loc.lon = res.location.longitude;
this.locationsService.addLocation(newLoc);
});
}
We will also need to import ourWeather
component as well:
import { WeatherPage } from '../weather/weather';
So now we can take city name, parse it into a latitude and longitude value, and add it to our list of locations.
There are a couple of open issues I do want to point out with this code block. The first issue is that we are not handling the case of when the geocoding service fails to find a location. If you are up for that programming challenge, you need to add code that rejects the Promise. The second issue is handling when the geocoding service returns more than one answer. For example, if I enter Paris, do I want Paris, France or Paris, Texas? For this solution, you might want to look at the flexibility of theAlert
component to have radio buttons in a dialog.
Dynamically Updating the Side Menu
If you add a new location to your list and then use the side menu to view that location’s weather, you will see that it is not listed. That is because that list is still referencing the local version and not using the array inLocationsService
. We will open the_app.component.ts_file and refactor it to enable this.
First, we need to import the service:
import { LocationsServiceProvider } from '../providers/locations-service/locations-service';
Next, we need to include in our constructor:
constructor(public platform: Platform,
public statusBar: StatusBar,
public splashScreen: SplashScreen,
public locationsService: LocationsServiceProvider) {
this.initializeApp();
this.getMyLocations();
}
ThegetMyLocations
function will get the data from theLocationsService
provider and then populate thepages
array:
getMyLocations(){
this.locationsService.getLocations().then(res =
>
{
this.pages = [
{ title: 'Edit Locations', component: LocationsPage, icon: 'create' },
{ title: 'Current Location', component: WeatherPage, icon: 'pin' }
];
for (let newLoc of res) {
this.pages.push(newLoc);
}
});
}
Save all the files and run$ ionic serve
again. The side menu should still show our initial list of places. If we add a new location, the Edit Locations screen is properly updated. However, the side menu is not showing our new location. Even though the side menu is getting its data from our shared provider, it does not know that the data has changed. To solve this issue, we need to explore two different options: Ionic Events and Observables. You might want to save a version of the progress so far, since each solution is a bit different.
Ionic Events
The first solution is to use IonicEvents
to communicate the change in the dataset. According to the documentation:
Events is a publish-subscribe style event system for sending and responding to application-level events across your app.
Sounds pretty close to what we need to do. In the_locations.ts_file, we will need to import theEvents
component fromionic-angular
:
import {NavController, NavParams, AlertController, Events} from 'ionic-angular';
Within the constructor we need to have a reference to theEvents
module:
constructor(public navCtrl: NavController,
public navParams: NavParams,
public locationsService: LocationsServiceProvider,
public geocodeService: GeocodeService,
public alertCtrl: AlertController,
public events: Events) {
Now, in both thedeleteLocation
andaddLocation
functions, we just need to add this code to publish the event:
this.events.publish('locations:updated', {});
So thedeleteLocation
becomes:
deleteLocation(loc) {
this.locations.removeLocation(loc);
this.events.publish('locations:updated', {});
}
For theaddLocation
function, it is added within the'Add'
handler callback:
handler: data =
>
{
if (data.title != '') {
this.geocode.getLatLong(data.title).then(res =
>
{
let newLoc = { title: '', component: WeatherPage, icon: 'pin', ↵
loc: { lat: 0, lon: 0 } }
newLoc.title = res.name;
newLoc.loc.lat = res.location.latitude;
newLoc.loc.lon = res.location.longitude;
this.locations.addLocation(newLoc);
this.events.publish('locations:updated', {});
});
}
}
The parameters are the event name—in this case,locations:updated
, and any data that needs to be shared. For this example, there is no additional data we need to send to the subscriber.
The subscriber function will be added to the_app.component.ts_file. First, we need to update our imports:
import { Nav, Platform, Events } from 'ionic-angular';
and pass it into our constructor:
constructor(public platform: Platform,
public locationsService: LocationsServiceProvider,
public events: Events) {
Within the constructor itself, we will add this code:
this.initializeApp();
this.getMyLocations();
events.subscribe('locations:updated', (data) =
>
{
this.getMyLocations();
});
This code will listen for alocations:updated
event. Then it will call thegetMyLocations
function. In doing so, our array will be refreshed, and the side menu will be kept up to date.
Using IonicEvents
may not be the best solution to this problem, but it is worth knowing how to communicate events across an application.
Observables
You might be wondering if there was some other way for the data updates to propagate through our app without the need to manually send events. In fact, there is a solution available to us. One of the elements inside the RxJS library is Observables. From the documentation:
The Observer and Observable interfaces provide a generalized mechanism for push-based notification, also known as the observer design pattern. The Observable object represents the object that sends notifications (the provider); the Observer object represents the class that receives them (the observer).
In other words, the event notification system that we wrote with IonicEvents
can be replaced. In our simple example using IonicEvents
, we did not have to write a lot of code for our system to work. Now imagine a much more complex app and the messaging infrastructure that could quickly become a Gordian knot.
Now, to say that RxJS is a powerful and complex library is an understatement. But here are the basics of what we need to do within our app. First, we need to create an RxJS Subject, specifically a Behavior Subject. This will hold our data that we wish to monitor for changes. Next, we need to create the actual Observable that will watch our subject for changes. If it sees a change, it will broadcast the new set of data. The third and final part are the subscribers to ourObservable
. Once they are bound together, our data will always be the latest.
We will start our refactoring in the_locations-service.ts_file. This is where the majority of the changes will occur as we shift our app from using IonicEvents
toObservables
.
As always, we need to import the needed modules:
import { Observable, BehaviorSubject } from 'rxjs/Rx';
Now, there are several types of Subject in the RxJS library. TheBehaviorSubject
is the best type for our needs, as it will send an update as soon as it gets data. Other types of Subjects require an additional method call to broadcast an update.
Next, we need to define the actualBehaviorSubject
andObservable
within the class:
locations: Array
<
WeatherLocation
>
;
locationsSubject: BehaviorSubject
<
Array
<
WeatherLocation
>
>
=
new BehaviorSubject([]);
locations$: Observable
<
Array
<
WeatherLocation
>
>
=
this.locationsSubject.asObservable();
VARIABLE NAMING
The inclusion of the$
after the variable name is a naming convention forObservable
data types. To learn more about Angular best practices, seeJohn Papa’s Style Guide.
We have only associated thelocations$
with thelocationsSubject
by setting it tothis.locationsSubject.asObservable()
. The locationsSubject knows nothing about our data in thelocations
array. To solve this issue, simply pass in our locations array as the parameter to the locationsSubject’s next method:
this.locationsSubject.next(this.locations);
Upon doing this, ourObservable
will emit an update event, and all the references will be changed. Rather than repeating this call whenever ourlocations
array changes, let’s wrap it in arefresh
function:
refresh() {
this.locationsSubject.next(this.locations);
}
Then in theconstructor
,addLocation
, andremoveLocation
methods, we can call this method after we have done whatever changes we needed to make to thelocations
array. Here is the completed code:
import { Injectable } from '@angular/core';
import { WeatherLocation } from '../../interfaces/weather-location';
import { Weather } from '../../pages/weather/weather';
import { Observable, BehaviorSubject } from 'rxjs/Rx';
@Injectable()
export class LocationsServiceProvider {
locations: Array
<
WeatherLocation
>
;
locationsSubject: BehaviorSubject
<
Array
<
WeatherLocation
>
>
=
new BehaviorSubject([]);
locations$: Observable
<
Array
<
WeatherLocation
>
>
=
this.locationsSubject.asObservable();
constructor() {
this.locations = [
{ title: 'Cape Canaveral, FL', component: Weather, icon: 'pin', ↵
loc: { lat: 28.3922, lon: -80.6077 } },
{ title: 'San Francisco, CA', component: Weather, icon: 'pin', ↵
loc: { lat: 37.7749, lon: -122.4194 } },
{ title: 'Vancouver, BC', component: Weather, icon: 'pin', ↵
loc: { lat: 49.2827, lon: -123.1207 } },
{ title: 'Madison, WI', component: Weather, icon: 'pin', ↵
loc: { lat: 43.0742365, lon: -89.381011899 } }
];
this.refresh();
}
getLocations() {
return Promise.resolve(this.locations);
}
removeLocation(loc) {
let index = this.locations.indexOf(loc)
if (index != -1) {
this.locations.splice(index, 1);
this.refresh();
}
}
addLocation(loc) {
this.locations.push(loc);
this.refresh();
}
refresh() {
this.locationsSubject.next(this.locations);
}
}
With the service converted to using anObservable
, we need to create the data subscribers to it. In_locations.ts_we now need to replace:
locationsService.getLocations().then(res =
>
{
this.locs = res;
});
with:
locationsService.locations$.subscribe( ( locs: Array
<
WeatherLocation
>
) =
>
{
this.locs = locs;
});
Now ourlocs
array will be automatically updated whenever the data changes in our service. Go ahead and remove the twothis.events.publish
calls.
A similar change is needed the_app.component.ts_file. First, let’s remove the event listener:
events.subscribe('locations:updated', (data) =
>
{
this.getMyLocations();
});
Next, we need to include theWeatherLocation
interface in the imports:
import { WeatherLocation } from '../interfaces/weather-location';
Finally, we can replace thegetMyLocations
function:
getMyLocations(){
this.locationsService.getLocations().then(res =
>
{
this.pages = [
{ title: 'Edit Locations', component: LocationsPage, icon: 'create' },
{ title: 'Current Location', component: WeatherPage, icon: 'pin' }
];
for (let newLoc of res) {
this.pages.push(newLoc);
}
});
}
with:
getMyLocations(){
this.locationsService.locations$.subscribe( ( locs: Array
<
WeatherLocation
>
) =
>
{
this.pages = [
{ title: 'Edit Locations', component: LocationsPage, icon: 'create' },
{ title: 'Current Location', component: WeatherPage, icon: 'pin' }
];
for (let newLoc of locs) {
this.pages.push(newLoc);
}
} );
}
With that, our weather app is now using RxJS Observables. Observables are a power solution when working with dynamic data. You would be well served to spend some time exploring their capabilities. Let’s now turn our attention to making our app a bit more visually pleasing.
Styling the App
With our app now functioning rather well, we can turn our attention to some visual styling. Included with the source code is a nice photograph of a partially cloudy sky. Let’s use this as our background image. Since our Ionic apps are based on HTML and CSS, we can leverage our existing CSS skills to style our apps. The only challenge in working with Ionic is uncovering the actual HTML structure that our CSS needs to properly target.
With the improved selector system within Ionic, targeting a specific HTML element is much easier. Let’s include a nice sky image to serve as our background for the Weather page. Open the_weather.scss_and add the following CSS:
page-weather {
ion-content{
background: url(../assets/imgs/bg.jpg) no-repeat center center fixed;
background-color: rgba(255, 255, 255, 0) !important;
-webkit-background-size: cover;
background-size: cover;
}
}
WRITING SASS
The CSS that we are adding should be nested within the page-weather {}.
Now, let’s increase our base font size as well to make the text more readable:
ion-content{
background: url(../assets/imgs/bg.jpg) no-repeat center center fixed;
-webkit-background-size: cover;
background-size: cover;
font-size: 24px;
}
However, the default black text is not really working against the sky and clouds. So we can adjust theion-col
and its two children. We will change the color to white, center the text, and apply a drop shadow that has a little transparency:
ion-col, ion-col h1, ion-col p {
color: #fff;
text-align: center;
text-shadow: 3px 3px 3px rgba(0,0, 0, 0.4);
}
Now, adjust the current weather information’s text:
h1 {
font-size: 72px;
}
p {
font-size: 36px;
margin-top: 0;
}
But, what about the header? It is looking a bit drab and out of place. Let’s add a new class,opaque
, to the<ion-header>
tag in the_weather.html_file.
In the_weather.scss_file we can apply a series of classes to give our header a more modern look. The first class we add will use the newbackdrop-filter
method:
.opaque {
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
}
Unfortunately, backdrop-filter is only supported in Safari at this time. This means we need to also create a fallback solution:
.opaque .toolbar-background {
background-color: rgba(#f8f8f8, 0.55);
}
With this pair of CSS classes, we have a much more current design style in place.
But there are still more elements to style on this screen, namely theRefresher
component. This one is a bit more complex than components we have styled before. Namely, it has a series of states that each need to be styled.
Let’s start with the two text elements; the pulling text and the refreshing text. Unfortunately, the current documentation does not list out the structure or style method. But with a little inspection with the Chrome Dev Tools, these elements did already have CSS classes applied to them. So we can just add the following:
.refresher-pulling-text, .refresher-refreshing-text {
color:#fff;
}
The arrow icon is also easily styled using:
.refresher-pulling-icon {
color:#fff;
}
But what about the spinner? The dark circles aren’t really standing out against our background. This element is a little tricky to style as well. The spinner is actually an SVG element. This means we cannot change it just by changing the CSS of the<ion-spinner>
, but instead we need to modify the values within the SVG. One thing to note is that some of the CSS properties on an SVG element have different names. For example, SVG uses the termstroke_instead of_border, andfill_instead of_background-color.
If you are using the circles option for yourrefreshingSpinner
attribute, then the styling is:
.refresher-refreshing .spinner-circles circle{
fill:#fff;
}
But, say you decided to use crescent as your spinner, then the styling would be:
.refresher-refreshing .spinner-crescent circle{
stroke:#fff;
}
There is one last item that we should change. You probably did not see that while the Refresher is visible, there was a solid 1-pixel white line between the Refresher and the content. With our full background image, this doesn’t quite work for this design. Again, with some inspection with Chrome Dev Tools, the CSS was found where this attribute was set. So, we can now override it with:
.has-refresher
>
.scroll-content {
border-width: 0;
}
Here is the full_weather.scss_code:
page-weather {
ion-content{
background: url(../assets/imgs/bg.jpg) no-repeat center center fixed;
-webkit-background-size: cover;
background-size: cover;
font-size: 24px;
}
ion-col, ion-col h1, ion-col p {
color: #fff;
text-align: center;
text-shadow: 3px 3px 3px rgba(0,0, 0, 0.4);
}
h1 {
font-size: 72px;
}
p {
font-size: 36px;
margin-top: 0;
}
.opaque {
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
}
.opaque .toolbar-background {
background-color: rgba(#f8f8f8, 0.55);
}
.refresher-pulling-text, .refresher-refreshing-text {
color:#fff;
}
.refresher-pulling-icon {
color:#fff;
}
.refresher-refreshing .spinner-circles circle{
fill:#fff;
}
.has-refresher
>
.scroll-content {
border-top-width: 0;
}
}
See the style weather app inFigure 9-6.
Figure 9-6.Our styled Ionic weather app
With that our main weather page is looking rather nice.But we can add one more touch to the design. How about a nice icon as well?
Add a Weather Icon
The Dark Sky data set actually defines an icon value, and the Ionicons also support several weather icons as wells.Table 9-1maps each icon.
Dark Sky name | Ionicon name |
clear-day | sunny |
clear-night | moon |
rain | rainy |
snow | snow |
sleet | snow |
wind | cloudy |
fog | cloudy |
cloudy | cloudy |
partly-cloudy-day | partly-sunny |
partly-cloudy-night | cloudy-night |
We don’t have a complete one-to-one mapping, but it is close enough. To resolve the mapping between the Dark Sky name and the Ionicon name, we will create a customPipe
function. If you recall,Pipes
are functions that transform data in a template.
From our terminal, we can use theionic generate
command to scaffold our pipe for us:
$ ionic generate pipe weathericon
This will create a new directory namedpipes, and a new file named_weathericon.ts._Here is the stock code that Ionic will generate for us:
import { Pipe, PipeTransform } from '@angular/core';
/**
* Generated class for the WeathericonPipe pipe.
*
* See https://angular.io/docs/ts/latest/guide/pipes.html for more info on
* Angular Pipes.
*/
@Pipe({
name: 'weathericon',
})
export class WeathericonPipe implements PipeTransform {
/**
* Takes a value and makes it lowercase.
*/
transform(value: string, ...args) {
return value.toLowerCase();
}
}
Let’s replace the code within thetransform
function. The goal of this function will be to take the Dark Sky icon string and find the corresponding Ionicon name. Here is the revisedPipe
:
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'weathericon',
})
export class WeathericonPipe implements PipeTransform {
transform(value: string, args: any[]) {
let newIcon: string = 'sunny';
let forecastNames: Array
<
string
>
= ["clear-day", "clear-night", "rain", ↵
"snow", "sleet", "wind", "fog", "cloudy", "partly-cloudy-day", "partly-cloudy-night"];
let ioniconNames: Array
<
string
>
= ["sunny", "moon", "rainy", ↵
"snow", "snow", "cloudy", "cloudy", "cloudy", "partly-sunny", "cloudy-night"];
let iconIndex: number = forecastNames.indexOf(value);
if (iconIndex !== -1) {
newIcon = ioniconNames[iconIndex];
}
return newIcon;
}
}
In our_app.module.ts_file, we will automatically updated for us.
Finally, the last change is to markup in the_weather.html_file, which will become:
<
ion-col col-12
>
<
h1
>
{{currentData.temperature | number:'.0-0'}}
&
deg;
<
/h1
>
<
p
>
<
ion-icon name="{{currentData.icon | weathericon}}"
>
<
/ion-icon
>
↵
{{currentData.summary}}
<
/p
>
<
/ion-col
>
Figure 9-7shows the Ionic weather app after applying the weather Ionicon.
Figure 9-7.Ionic weather app with a weather Ionicons applied
That really does finish off the look of this screen. Feel free to continue to explore styling the side menu and the locations page on your own.
Next Steps
Our weather app still has some things that can be improved upon. Most notably, our custom cities are not saved. For something as simple as a list of cities, using Firebase might be overkill. Some options you might consider would be either local storage or using the Cordova File plug-in to read and write a simple datafile. The choice is up to you.
Another challenge you might consider would be to introduce dynamic backgrounds. You could use a Flickr API to pull an image based on the location or on the weather type.
One more challenge you could take on is to support changing the temperature units.
Summary
With this application, we explored how the sidemenu template functions. You were introduced to using Pipes to transform data within a template. We fetched data from an external source, and learned how to use the proxy system in Ionic to address any CORS issues. Finally we looked at both Ionic Events and Observables as methods to update our UI dynamically.