Never miss a dismiss in Ionic anymore

Posted on Sep 26, 2017

Have you ever experienced, that some loading indicators just don’t want to dismiss and literally stay forever? Maybe while users tried to login? The short story: you probably forgot to dismiss a loading indicator in some cases.

Within this blogpost you will learn how to declaratively present loading indicators and how to prevent forgetting to dismissing it again.

In our showcase we have a login page, that shall present a loading indicator while the user will be authenticated. The page itself only consists of inputs for our credentials and a login button calling LoginPage.submitLogin on click inside ion-content.

<ion-content padding>
  <ion-input [(ngModel)]="username" type="text" value=""></ion-input>
  <ion-input [(ngModel)]="password" type="password" value=""></ion-input>
  <button ion-button (click)="submitLogin()"></button>
</ion-content>

Our LoginPage relies on an AuthenticationService, mocked as follows.

export abstract class AuthenticationService {
  public abstract login(username: string, password: string): Observable<void>;
}

Once the user clicked on the login button, we will create a Loading using the LoadingController, present it and perform the login itself. Thereafter we need to dismiss the same Loading again.

@IonicPage()
@Component({
  templateUrl: "./login.page.html"
})
export class LoginPage {
  public username: string;
  public password: string;

  constructor(
    private readonly authentication: AuthenticationService,
    private readonly navCtrl: NavController,
    private readonly loadingCtrl: LoadingController
  ) {}

  public submitLogin() {
    // configure loading spinner
    const loading = this.loadingCtrl.create();
    // present it
    loading.present().then(() =>
      // perform login
      this.authentication
        .login(this.username, this.password)
        .toPromise()
        // on success dismiss
        .then(() => loading.dismiss())
        // and apply successful login
        .then(() => this.navCtrl.setRoot("MyEntryPage"))
    );
  }
}

You might have noticed, but we already introduced a bug: if the login fails, our promise rejects and .then(() => loading.dismiss()) will never be called, our Loading will still be presented and the user will be forced to restart the whole app in order to try the correct credentials. The most obvious fix would be to just insert a catch which dismissed the loading, too. But this would not prevent any other usages of Loading from being presented forever.

In order to solve this problem, we will create a new controller, which will present loadings and automatically dismisses them once a given promise resolves or rejects. We want our submitLogin to be completely free from all loading.dismiss() calls and even the requirement to even keep our loading variable. Additionally we don’t want to be forced to either subscribe to an observable nor to convert it to a promise.

The code we want inside LoginPage.submitLogin should be focused and compact.

public submitLogin() {
  // present while ...
  this.activityCtrl
    .presentWhile(
      // ... logging in
      this.authentication.login(this.username, this.password)
    )
    // on success, apply login
    .then(() => this.navCtrl.setRoot("MyEntryPage"));
}

Our presentWhile will be implemented inside a service called ActivityController and will either work with promises or observables. It handles dismissing our loading spinner and returns a new promise resolving to the same value (or rejecting to the same error).

@Injectable()
export class ActivityController {
  constructor(private readonly loadingCtrl: LoadingController) {}

  public presentWhile<T>(
    active: Promise<T> | Observable<T>,
    options?: LoadingOptions
  ): Promise<T> {
    // create loading using optionally given options
    const spinner = this.loadingCtrl.create(options);
    return (
      spinner
        .present()
        // convert observables to promises
        .then(
          () => (active instanceof Observable ? active.toPromise() : active)
        )
        // dismiss and keep result
        .then(result => spinner.dismiss().then(() => result))
        // keep the error, but dismiss
        .catch(error => spinner.dismiss().then(() => Promise.reject(error)))
    );
  }
}

In order to actually use ActivityController in our LoginPage, we need to inject it. As there Ionic’s controllers (LoadingController, NavController, etc.) will be injected for each page separately, we need to do the same for each page, too. Inside your unit tests, you can override providers.

@IonicPage()
@Component({
  templateUrl: "./login.page.html",
  // add provider to component
  provders: [ActivityController]
})
export class LoginPage {
  // username, password

  constructor(
    private readonly authentication: AuthenticationService,
    private readonly navCtrl: NavController,
    private readonly activityCtrl: ActivityController
  ) {}

  // submitLogin()
}

Within this blogpost you have seen which problems may occur when using loading spinners and how to solve them in general. Furthermore you learnt how to create a new controller for Ionic and how to use it.

Do you have more ideas for using a pattern like this?