'Implementing navigation within a modal window in Ionic' post illustration

Implementing navigation within a modal window in Ionic

avatar

Modal dialogs are designed to bring important pieces of information to users from any page without changing an active routing state. It is often convenient to utilize a modal dialog that itself contains a number of views and its own routing. And, since the $ionicModal service does not provide this functionality, here is where custom modal implementations come into play.

This post shows how to create a custom modal dialog that provides the ability to navigate between its inner views. The most common example of the modal with inner navigation is the 'info' page which contains a list of sub-menus that break content down into categories.

In order to implement the 'info' modal, we will create two services. The first service will provide a generic modal implementation with methods for hiding and showing the modal dialog. And the second service will represent a specific modal that allows to create a routing configuration with inner views. Both services will be designed to work in conjunction with their companion directives for embedding modals into page layouts.

Step 1. A custom modal dialog

1.1 The service

Let's start with the customModal service:

customModal_service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
/**
 * Helps to access and manipulate custom modal instances.
 */
.factory('customModal', ['$ionicPlatform', '$ionicHistory', '$state', '$timeout',
    function ($ionicPlatform, $ionicHistory, $state, $timeout) {

  // container to hold all available modal instances
  var modals = [];

  // registering hardware `back` button handler...
  registerBackButtonAction();

  return {
    // returns a modal instance by id
    get: get,
    // attaches a directive handler which allows to display / hide the modal
    setHandler: setHandler
  };

  ////////////////////////////
  // *** Implementation *** //
  ////////////////////////////

  /**
   * Intercepts the hardware `back` button click on a mobile device.
   */
  function registerBackButtonAction() {
    // registers back button action which closes the modal if it is opened
    var priority = 500; // the highest priority for the action, please read
    // http://ionicframework.com/docs/api/service/$ionicPlatform/
    $ionicPlatform.registerBackButtonAction(backButtonAction, priority);

    // closes the modal if it is opened, otherwise executes 'go back' action.
    function backButtonAction() {
      // checks if there is a modal that is currently opened
      var modal = modals.find(function(modal) {
        return modal && modal.directiveHandler && !modal.directiveHandler.isHidden();
      });
      if (modal) {
        // closes the modal view if it is opened
        modal.close();
        // simulates state change in order to trigger the modal hiding
        $state.go($state.current.name);
      } else {
        // otherwise, checks if there is a way back
        if ($ionicHistory.viewHistory().currentView.backViewId === null) {
          // exists the app if there is no way back
          ionic.Platform.exitApp();
        } else {
          // or goes back to the previous page
          $ionicHistory.goBack();
        }
      }
    }
  }

  /**
   * Returns a modal instance by id.
   */
  function get(id) {
    return modals.find(function (modal) { return modal.id === id; }) ||
      createModal(id);
  }

  /**
   * Attaches a directive handler which is used to display / hide the modal.
   */
  function setHandler(id, handler) {
    get(id).directiveHandler = handler;
  }

  /**
   * Creates a new modal instance.
   */
  function createModal(id) {

    var modal =  {
      // unique modal identifier
      id: id,
      // a set of dummy callback functions which can be defined in a controller
      callbacks: ['beforeOpened', 'afterOpened', 'beforeClosed', 'afterClosed'].reduce(
        function(result, item) {result[item] = function(){}; return result;}, {}),
      // shows the modal
      show: show,
      // hides the modal
      close: close
    };
    // adds modal to the array with the other modals.
    modals.push(modal);
    return modal;

    /**
     * Triggers the 'open' event, and executes callbacks.
     */
    function show() {
      this.callbacks.beforeOpened();
      this.directiveHandler.show();
      this.callbacks.afterOpened();
    }

    /**
     * Triggers the 'close' event, and executes callbacks.
     */
    function close() {
      this.callbacks.beforeClosed();
      this.directiveHandler.close();
      // wait till window is closed, and only then perform DOM manipulations
      $timeout(this.callbacks.afterClosed, 500);
    }

  }

}]);

Two main things that the customModal service does are:

  • provides custom modal instances to the user to show / hide a modal by id
  • processes the hardware 'back` button click in order to close an active modal

To get a modal instance in a controller, the get function should be invoked (line 15). That function requires only the id parameter which should match the 'id' of the directive (will be described in the next section). Every modal instance provides show() and close() methods and a set of dummy callback functions callbacks that intended to be defined in a controller.

1.2 The directive

The best approach here would be to design the directive that automatically registers its visibility handler during the initialization using the setHandler method in the custom modal service:

customModal_directive
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
/**
* Renders modal template.
*/
.directive('customModal', ['customModal', '$compile',
  function (customModal, $compile) {

  return {
    restrict: 'E',
    transclude: true,
    link: link
  };

  ////////////////////////////
  // *** Implementation *** //
  ////////////////////////////

  function link(scope, element, attrs, ctrl, transclude) {

    // gets the directive id
    var id = attrs.id;
    // creates a unique variable name for `ng-hide`
    var ngHideBinder = "hidden_" + id;
    var modalEl = '<ion-pane ng-hide="' +
      ngHideBinder +
      '" class="menu-animation ng-hide"></ion-pane>';
    scope[ngHideBinder] = true;
    // gets the directive content and appends it to a modal element
    transclude(function(clone) {
      var wrapper = $compile(modalEl)(scope);
      element.append(wrapper.append(clone));
    });
    // registers directive handler in the customModal service
    customModal.setHandler(id, handler());

    //////////////////////////////////

    // object that provides methods for the modal visibility handling
    function handler() {
      return {
        show: show,
        close: close,
        isHidden: isHidden
      };

      // shows the modal
      function show() {
        scope[ngHideBinder] = false;
      }

      // hides the modal
      function close() {
        scope[ngHideBinder] = true;
      }

      // checks if the modal is hidden / visible
      function isHidden() {
        return scope[ngHideBinder];
      }
    }

  }

}]);

The implementation of the directive above is quite simple — the handler function provides an object with methods for hiding and showing the modal and is being passed to the customModal service.

At this point, we already have all the functionality in place for creating basic modal dialogs. For instance, to create a custom modal in your app, you can add the custom-modal directive to the html page and then refer to its instance from a controller using the customModal service and the modal element id:

file.html
1
2
3
4
<!-- the directive should be placed next to the app container element. -->
<custom-modal id="modal1">
    Modal content goes here...
</custom-modal>
controller.js
1
2
3
4
5
6
7
8
9
10
11
12
  // loads modal instance
  var simpleModal = customModal.get('modal1');

  // triggers modal opening
  $scope.showInfo = function () {
    simpleModal.show();
  };

  // triggers modal hiding
  $scope.closeInfo = function () {
    simpleModal.close();
  }

Step 2. A custom modal with inner routing

2.1 The service

Now, when the simple custom modal is implemented, let's move ahead and build an extended version that will provide us with the basic inner routing. The service for manipulating modal state will be called multiViewModal and will use the customModal implementation in order to provide basic modal capabilities:

multiViewModal_service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
/**
 * Helps to access and manipulate navigatable modal instances with
 * multiple inner views.
 */
.factory('multiViewModal', ['customModal', function (customModal) {

  // container to hold all available modal instances
  var modals = [];

  // the methods defined bellow partially replicate the methods of 'custom modal' service
  // you might consider moving them to a 'base modal' service for a real-life application
  return {
    get: get,
    setHandler: setHandler
  };

  ////////////////////////////
  // *** Implementation *** //
  ////////////////////////////

  /**
   * Returns a modal instance by id.
   */
  function get(id) {
    return modals.find(function (modal) { return modal.baseModal.id === id; }) ||
      createModal(id);
  }

  /**
   * Attaches a directive handler which allows to manipulate modal state.
   */
  function setHandler(id, handler) {
    get(id).directiveHandler = handler;
  }

  /**
   * Creates a new modal instance.
   */
  function createModal(id) {
    var modal = {
      show: show,
      close: close,
      baseModal: customModal.get(id),
      // activates view with the given name
      activateView: activateView,
      // activates the previous view in hierarchy
      previousView: previousView
    };
    modal.baseModal.callbacks.afterClosed = afterClosed(modal);
    // adds modal to the array with the other modals.
    modals.push(modal);
    return modal;

    /**
     * Triggers the 'open' event.
     */
    function show() {
      this.baseModal.show();
    }

    /**
     * Triggers the 'close' event.
     */
    function close() {
      this.baseModal.close();
    }

    /**
     * Activates view with the given name.
     */
    function activateView(name) {
      this.directiveHandler.activateView(name);
    }

    /**
     * Activates the previous view in hierarchy.
     */
    function previousView() {
      this.directiveHandler.previousView();
    }

    /**
     * Clears inputs and pre-activates the root view if required.
     */
    function afterClosed(modal) {
      var m = modal;
      return function () {
        var handler = m.directiveHandler;
        // `erasable` determines if all input data is erased after the modal is closed
        if (handler.options.erasable) handler.clearInputs();
        // `returnable` determines if the root view should be displayed to the user
        // when the modal is opened for the next time
        if (handler.options.returnable) handler.activateRoot();
      };
    }
  }

}]);

The multiViewModal service has the same structure as the customModal:

  • The get method returns a multi view modal instance by id (line 11). The visibility control is performed via show and close methods of customModal. Also, the multiViewModal service adds two more functions, called activateView and previousView. Those functions are responsible for the routing between views (how the views are initialized will be shown shortly).
  • The setHandler method assigns directive handler to the modal instance with an appropriate 'id'.

2.2 The directive

The next code snippet contains a directive called multi-view-modal that is used in conjunction with the multiViewModal service:

multiViewModal_directive
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
.directive('multiViewModal', ['multiViewModal', '$compile',
  function (multiViewModal, $compile) {

  return {
    restrict: 'E',
    link: link
  };

  ////////////////////////////
  // *** Implementation *** //
  ////////////////////////////

  function link(scope, element, attrs) {

    var id = attrs.id;
    var views = 'view_' + id;

    // reads views defined as child directive elements
    scope[views] = readViews(element.children());
    // sets 'isActive' flag for the root view
    scope[views].find(function(view) { return view.root; }).isActive = true;

    // reads options defined as directive attributes
    var options = {
      // controls if all data should be erased after a modal is closed
      erasable:   attrs.erasable ? attrs.erasable === 'true' : true,
      // controls if the current active view will remain active when the modal reopened
      returnable: attrs.returnable ? attrs.returnable === 'true' : true
    };

    // initializes and updates directive template
    var viewsTemplate = '<ion-pane ng-repeat="item in ' + views + '"' +
      ' ng-show="item.isActive" ng-include="item.url"></ion-pane>';

    var baseModalTemplate = '<custom-modal id="' + id + '">' +
        viewsTemplate +
      '</custom-modal>';

    var baseModal = $compile(baseModalTemplate)(scope);
    element.replaceWith(baseModal);

    // registers directive handler in the multiViewModal service
    multiViewModal.setHandler(id, handler());

    /**
     * Retrieves an information about the views
     * from the directive child elements and their attrs.
     */
    function readViews(childElements) {
      return Array.from(childElements).reduce(function (views, viewItem) {
        if (viewItem.localName === 'view-item') {
          views.push(
            ['name', 'url', 'root', 'parent'].reduce(function(view, attrName) {
              var attribute = viewItem.attributes[attrName];
              if (attribute) {
                var value = attribute.value;
                view[attrName] =
                  value === 'true' ||
                  value === 'false' ? value === 'true' : value;
              }
              return view;
            }, {})
          );
        }
        return views;
      }, []);
    }

    /**
     * Creates a handler to manipulate the directive state.
     */
    function handler() {
      return {
        options: options,
        activateRoot: activateRoot, // activates the root view
        activateView: activateView, // activates view with the given name
        previousView: previousView, // activates the previous view in hierarchy
        clearInputs:  clearInputs   // clears all inputs by recompiling the modal
      };

      // sets view with an appropriate name as active.
      function activateView(name) {
        scope[views].forEach(function (view) {
          if (view.name === name)
            view.isActive = true;
          else
            delete view.isActive;
        });
      }

      // activates the root view
      function activateRoot() {
        activateView(scope[views]
          .find(function (view) { return view.root; }).name);
      }

      // goes back to the previous view.
      function previousView() {
        activateView(scope[views]
          .find(function (view) { return view.isActive; })
          .parent);
      }

      // clears inputs by recompiling the modal
      function clearInputs() {
        var pane = angular.element(baseModal.children()[0]);
        pane.empty();
        pane.append($compile(viewsTemplate)(scope));
      }
    }
  }
}]);

The multi-view-modal directive creates the views template, initializes its handler object, and then passes the handler to the multiViewModal service.

To create a modal with inner navigation, you need to add the <multi-view-modal> directive next to the app's container element and invoke the get method in the controller:

file.html
1
2
3
4
5
6
<!-- The directive should be placed next to the app container element -->
<multi-view-modal id="modal2" erasable="false" returnable="true">
  <view-item name="home" url="templates/info-home.html" root="true"/>
  <view-item name="section1" url="templates/info-section-1.html" parent="home"/>
  <view-item name="section2" url="templates/info-section-2.html" parent="home"/>
</multi-view-modal>
controller.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  var multiPageModal = multiViewModal.get('modal2');

  $scope.showInfo = function(){
    multiPageModal.show();
  };

  $scope.closeInfo = function () {
    multiPageModal.close();
  };

  $scope.activateMenu = function (name) {
    multiPageModal.activateView(name);
  };

  $scope.previous = function () {
    multiPageModal.previousView();
  };

The <view-item> elements, that is being wrapped in the <multi-view-modal> directive, should describe the separate view elements and their routing position relative to the others:

  • name: unique view name;
  • url: link to the html template for this view;
  • root: boolean parameter which indicates that the page is a root element, i.e. shown to the user right after the modal is opened;
  • parent: name of the view that should be opened when the back button is clicked (should be defined for all the views except the root).

The examples, that show how these services can be used in the the app, are available on Plunker and GitHub.

If you're looking for a developer or considering starting a new project,
we are always ready to help!