Ionic Settings Screen - smukov/AvI GitHub Wiki
After I implemented the Settings Screen and the required background logic in Android Native in a matter of minutes, I decided to do the same with Ionic2, and it took me 100 times longer to do it. Let's see why.
As always, I needed to add the required foundation for my new Settings Page by adding a few files and gluing them together in the app. The process was repeated at least 5 times before so I won't go through it again. You can see what I did in my commit.
The first headache I had was choosing the approach to take when it comes to storing and displaying user preferences in the Ionic2 app. When I did the same thing in Native Android, it was pretty straightforward, documentation suggested the best way to do this and I managed to implement it in 5 minutes by just following the official Android documentation. I was hoping for a similar treatment in Ionic2, however, I didn't get it.
I first went to Ionic2 official documentation to see if I could find a component or some kind of guidance for this, but there was nothing. The useful things I could find is a different storage options I could use and, at the moment of writing, the documentation for them was not very detailed. Basically, there are two options:
- LocalStorage - a quick and easy storage engine that uses the browser's local storage system for storing key/value pairs. However, this storage is recommended to be used with temporary data only that the app can afford to lose. Given disk space constraints on a mobile device, local storage might be "cleaned up" by the operating system (iOS).
-
SqlStorage - SqlStorage uses SQLite or WebSQL to store data in a persistent SQL store on the filesystem. This is the preferred storage engine, as data will be stored in appropriate app storage, unlike Local Storage which is treated differently by the OS. For convenience, the engine supports key/value storage for simple get/set and blob storage. The full SQL engine is exposed underneath through the
query
method.
It doesn't take a lot of thought to choose between these two options - user preferences need to be persisted and not lost at random, so we'll go with SqlStorage engine.
So that's the storage part. However, I've read somewhere that each mobile platform has their own way of presenting and storing the user preferences. I knew that they have a specific way for storing them, but I didn't notice anything too specific about the presentation part. In my opinion all Android applications are presenting preferences in a similar, but not exactly the same way. Every app has it customized to meet the rest of the layout. And in regard to iOS and Windows Phone, well, I never owned any of the two and, thus, have had very little contact with those platforms, so I cannot really comment. Nevermind, I decided to search the web for a way to present the App Preferences in a platform specific way in Ionic 2.
I found some 3rd party libraries that some people used with Ionic 1. I thought about using them, but for some reason my gut feeling told me not to go that way. Not sure why, but they just didn't gave me enough confidence, or reason, to use them. So in the end, after quite some time of searching and reading I decided to implement everything manually, and not to worry too much about platform specific presentation right now.
Using the SqlStorage
engine requires the cordova-sqlite-storage
plugin to be installed in our project. Otherwise, the SqlStorage
will use the WebSQL instead of SQLite, and WebSQL is recommended to be used only during development stage. Because of this I decided to install the cordova-sqlite-storage
plugin immediately, and a way to do this is by simply executing the following line in the console:
ionic plugin add cordova-sqlite-storage
This command should download the required files to your project and update some existing files that should wire it all up with the rest of your project. Sounds great, at least in theory.
I had lost approximately 4 hours of my life trying to figure out why my SqlStorage
doesn't work when I run the application in an Android emulator. Everything was running great in a browser, however, in an emulator the application just wouldn't start using the database.
Without going into details with what I did to try and make it work (it includes swearing at the code among other things), here's a single commit that made the SqlStorage work in Android emulator. The key is in this line in Ionic/plugins/android.json file:
{
"xml": "<feature name=\"SQLitePlugin\"><param name=\"android-package\" value=\"io.sqlc.SQLitePlugin\" /></feature>",
"count": 1
}
I have no idea why this line didn't get added when I run the plugin add
command. I ended up deducting somehow from the remaining code in the same file that a line above was missing. The moment I added it manually, I deployed a new version of the app to the emulator and everything finally started working.
Back to the implementation. I decided to create two data providers at this point. One I'll call StorageService
and it'll be the only service through which my app will communicate with a database. The second one will be PreferencesService
and it will be used to manipulate the user preference settings specifically.
First I'll create the StorageService
in the app/services/storage.service.js file.
import {Storage, SqlStorage} from 'ionic-angular';
import {Injectable} from '@angular/core';
@Injectable()
export class StorageService {
constructor() {
this.storage = new Storage(SqlStorage);
}
}
Nothing special about this class. It will be used as a singleton across my application, and will probably grow in time with additional helper methods to handle the data. For now, this is all I need, a single place to handle my connection with the database.
Next, I'll create a PreferencesService
in the app/services/preferences.service.js:
import {Storage, SqlStorage} from 'ionic-angular';
import {Injectable} from '@angular/core';
import {StorageService} from './storage.service';
@Injectable()
export class PreferencesService {
static get PREF_INITIALIZED() { return 'preferencesInitialized';}
static get PREF_DISCOVERABLE() { return 'pref_discoverable';}
static get PREF_NOTIFY_MESSAGES() { return 'pref_notification_messages';}
static get PREF_NOTIFY_INVITES() { return 'pref_notification_invites';}
static get parameters() {
return [[StorageService]];
}
constructor(storageService) {
this._storageService = storageService;
this._preferences = {};
}
initializePreferences(){
console.log('initializePreferences');
this._storageService.storage.get(PreferencesService.PREF_INITIALIZED).then((result) => {
if(result == null || result == false){
console.log('initializePreferences with default values');
this._storageService.storage.set(PreferencesService.PREF_INITIALIZED, true);
this._storageService.storage.set(PreferencesService.PREF_DISCOVERABLE, true);
this._storageService.storage.set(PreferencesService.PREF_NOTIFY_MESSAGES, true);
this._storageService.storage.set(PreferencesService.PREF_NOTIFY_INVITES, true);
//initialize in memory preferences
this._preferences[PreferencesService.PREF_DISCOVERABLE] = true;
this._preferences[PreferencesService.PREF_NOTIFY_MESSAGES] = true;
this._preferences[PreferencesService.PREF_NOTIFY_INVITES] = true;
}else{
console.log('preferences obtained from storage');
let prefs =
[
PreferencesService.PREF_DISCOVERABLE,
PreferencesService.PREF_NOTIFY_MESSAGES,
PreferencesService.PREF_NOTIFY_INVITES
];
let thisRef = this;
this._getAllPreferences(prefs).then(function(results){
//initialize in memory preferences
for(let i = 0; i < prefs.length; i++){
thisRef._preferences[prefs[i]] = results[i];
}
}, function (err) {
// If any of the preferences fail to read, err is the first error
console.log(err);
});
}
});
}
getPreference(key){
return this._preferences[key];
}
setPreference(key, value){
this._preferences[key] = value;//update pref in memory
this._storageService.storage.set(key, value);//update pref in db
}
_getAllPreferences(prefs){
return Promise.all(prefs.map((key) => {
return this._storageService.storage.get(key);
}));
}
_getPreference(key){
return this._storageService.storage.get(key);
}
}
The idea behind this service is that it should handle all user preferences data. Application shouldn't use any other way to retrieve or persist this data, it should always call this service, which will also be a singleton.
static get parameters() {
return [[StorageService]];
}
The above code will inject the StorageService
instance into the constructor of the PreferencesService
. The StorageService
instance will actually be used to query and persist the user preferences in the local database.
The PreferencesService
also defines a couple of global static properties. These are the keys for our specific preference setting:
static get PREF_INITIALIZED() { return 'preferencesInitialized';}
static get PREF_DISCOVERABLE() { return 'pref_discoverable';}
static get PREF_NOTIFY_MESSAGES() { return 'pref_notification_messages';}
static get PREF_NOTIFY_INVITES() { return 'pref_notification_invites';}
We can use these properties throughout the app with PreferencesService.PREF_DISCOVERABLE
syntax.
In order to achieve better performance, we are going to store all user preference settings in memory inside the this._preferences
variable. When the application asks for a specific setting, it will do that through the getPreference(key)
function, which will return a value from the in memory object.
In order to keep the in memory object in sync with the database, we are going to update both the object and the database during a single call to setPreference(key,value)
function:
setPreference(key, value){
this._preferences[key] = value;//update pref in memory
this._storageService.storage.set(key, value);//update pref in db
}
When the application is started it will call the initializePreferences()
function. This function will look for the PreferencesService.PREF_INITIALIZED
key in the database. If there is no such key in the database, that means that the application is started for the first time, so we need to initialize the preferences to default values.
However, if a value is retrieved, we need to read the user preferences from the storage. We are doing this with following code:
let prefs =
[
PreferencesService.PREF_DISCOVERABLE,
PreferencesService.PREF_NOTIFY_MESSAGES,
PreferencesService.PREF_NOTIFY_INVITES
];
let thisRef = this;
this._getAllPreferences(prefs).then(function(results){
//initialize in memory preferences
for(let i = 0; i < prefs.length; i++){
thisRef._preferences[prefs[i]] = results[i];
}
}, function (err) {
// If any of the preferences fail to read, err is the first error
console.log(err);
});
The above code is calling the _getAllPreferences(prefs)
function by providing the prefs
array with all the keys by which preferences are stored. When the database returns all the values back, the code is iterating through the results and is updating the in memory _preferences
object that the application can then read.
_getAllPreferences(prefs){
return Promise.all(prefs.map((key) => {
return this._storageService.storage.get(key);
}));
}
The _getAllPreferences(prefs)
function requires a short explanation. Basically, when we are calling the storage.get(key)
function, we are getting a Promise
back from the SqlStorage
engine as the data is being retrieved asynchronously so that it doesn't freeze our UI thread.
Since we are retrieving multiple values from the storage at once, we need to handle multiple promises, and we want to handle them together once they all return some data. To do this, we are using the Promise.all(..)
method.
The Promise.all(iterable)
method returns a promise that resolves when all of the promises in the iterable
argument have resolved, or rejects with the reason of the first passed promise that rejects.
The prefs.map(..)
will call the inline function once for each preference key in the prefs
array, and every inline function that is called will return a promise, that will be handled at the same time as one single promise by Promise.all(..)
method.
A better and faster way to handle the storing of user preferences is by using a JSON object to group all the information together and store them under a single key.
Our above preferences could all be stored under a single key like this:
let preferences =
{
discoverable: true,
notify_messages: true,
notify_invites: true
};
this._storageService.storage.set('preferences', JSON.stringify(preferences));
Then, when retrieved from the storage, they could be parsed back using the JSON.parse(..)
method.
I didn't think of this approach when I initially implemented my solution. I saw it later on in Josh Morony's blog post.
Before we start using them in pages, we need to bootstrap our data providers (services) in our app. We are doing this in our root component in the app.js file:
//import the services
import {StorageService} from './services/storage.service';
import {PreferencesService} from './services/preferences.service';
//code omitted for brevity
//bootstrap them
ionicBootstrap(MyApp, [StorageService, PreferencesService], {});
Now they are injectable throughout our application.
Also, if you take a look at the source code of the MyApp
root component, you'll notice that I'm also injecting the PreferencesService
in the root component, and then initializing the preferences via the preferencesService.initializePreferences()
method call in the platform.ready().then(() => {..})
promise. This is because the SqlStorage needs to wait for the Device Ready
event, and that is handled by the platform.ready()
.
Now that we have our data providers ready, it's time to start using them in our Settings screen.
The SettingsPage
logic is implemented in the app/pages/settingsPage/settingsPage.js below:
import {Component} from '@angular/core';
import {PreferencesService} from '../../services/preferences.service';
@Component({
templateUrl: 'build/pages/settingsPage/settingsPage.html'
})
export class SettingsPage {
static get parameters() {
return [[PreferencesService]];
}
constructor(preferencesService) {
this.preferencesService = preferencesService;
this.preferences = {};
this.PREF_DISCOVERABLE = PreferencesService.PREF_DISCOVERABLE;
this.PREF_NOTIFY_MESSAGES = PreferencesService.PREF_NOTIFY_MESSAGES;
this.PREF_NOTIFY_INVITES = PreferencesService.PREF_NOTIFY_INVITES;
}
ionViewWillEnter(){
this.preferences[PreferencesService.PREF_DISCOVERABLE]
= this.preferencesService.getPreference(PreferencesService.PREF_DISCOVERABLE);
this.preferences[PreferencesService.PREF_NOTIFY_MESSAGES]
= this.preferencesService.getPreference(PreferencesService.PREF_NOTIFY_MESSAGES);
this.preferences[PreferencesService.PREF_NOTIFY_INVITES]
= this.preferencesService.getPreference(PreferencesService.PREF_NOTIFY_INVITES);
}
changePreference(event, key){
this.preferencesService.setPreference(key, event.checked);
}
}
Nothing special there. We are injecting the PreferencesService
instance into the SettingsPage
component and then obtaining the required preferences in the ionViewWillEnter
event.
We will use the changePreference
method to update our preference storage with new data.
The below piece of code, that you can also find in the constructor above, might seem redundant. However, I needed to define these component variables in order to be able to use them in component's template, since I can't call a static method or variable from a template.
this.PREF_DISCOVERABLE = PreferencesService.PREF_DISCOVERABLE;
this.PREF_NOTIFY_MESSAGES = PreferencesService.PREF_NOTIFY_MESSAGES;
this.PREF_NOTIFY_INVITES = PreferencesService.PREF_NOTIFY_INVITES;
Now finally for the UI of the Settings page.
The UI definition (component's template) is stored in the app/pages/settingsPage/settingsPage.html file:
<!-- code omitted for brevity -->
<ion-content padding class="settingsPage">
<ion-list>
<ion-item>
<ion-label>Make me discoverable by others</ion-label>
<ion-toggle secondary
[ngModel]='preferences[PREF_DISCOVERABLE]'
(ionChange)='changePreference($event, PREF_DISCOVERABLE)'></ion-toggle>
</ion-item>
<ion-item>
<ion-label>Notifications for Messages</ion-label>
<ion-toggle secondary
[ngModel]='preferences[PREF_NOTIFY_MESSAGES]'
(ionChange)='changePreference($event, PREF_NOTIFY_MESSAGES)'></ion-toggle>
</ion-item>
<ion-item>
<ion-label>Notifications for Invites</ion-label>
<ion-toggle secondary
[ngModel]='preferences[PREF_NOTIFY_INVITES]'
(ionChange)='changePreference($event, PREF_NOTIFY_INVITES)'></ion-toggle>
</ion-item>
</ion-list>
</ion-content>
Again, nothing special about it. We are using the ion-toggle
component and are binding the ngModel
one-way from the model to the UI component. We'll handle the model update with the ionChange
event that is calling the changePreference
method that we defined in our SettingsPage
component.
You can also see how we are using the PREF_*
variables, that we defined earlier in the component, to avoid typing strings into our template.
Compared to Native Android implementation this was a pure nightmare. As I mentioned in the beginning, Android Native implementation took me literally 5 minutes, while the same thing in Ionic 2 took me 100 times longer.
The main reason behind the large amount of time I spent here is that I missed a clear guidance (or best practice article) that I found in Android's official documentation. However, I do understand that Ionic 2 needs to handle the storage and presentation of the user preferences on three different platforms at once, while the Android is just for Android, so Ionic2 approach is naturally more complex.
The second reason behind this large amount of time is the issue I had with cordova-sqlite-storage plugin that I decribed above.