[Window Management] Transition State Machine

- - posted in css,, javascript | Comments

The article is inspired by Tim’s CSS Classes State Machine Puzzle.

We had some frustrations all the times, on animating a window correctly.

Recently I try to dedicate on solving the puzzle. But at first I need to apologize for my poor knowledge and thought about css transitions using class names.

In the past I usually thought it was stupid to use class list to control the style. I thought it was ambiguous and strange if we ran into a situation that there’re more than one classes of same type put on one element. And an element full of different class names for different purposes may conflict. Recently I see Effeckt and know this is the trend: a simple class name stands for a kind of animation.

The real problem is that we should never put wrong class on the DOM element, in javascript. So this now turns to be a pure javascript puzzle.

We know, that, in certain moment, the app window is always in a specific state A, not B nor C. So what’s the problem? It’s how could we switch the state correctly.

Let’s create a real state machine in javascript!

So far it doesn’t take times for us to figure out that a window should have 4 basic transition state: closed, opening, opened, closing.

A basic transition life cycle of a window could be:

(initial) -> closed -> opening -> opened ——> closing -> opening ——> ….

Now we have 4 states, the next is what’s the trigger to states switching?

In finite state machine, what triggers state change is named for ‘event’.

We know that at least we have 2 events: ‘open’ and ‘close’.

  • ‘open’ would trigger closed switches to opening
  • ‘close’ would trigger opened switches to closing
  • If we sends ‘open’ event into the state machine continously, the second event would be ignored. (Exactly it depends on what policy we choose. For example, we could devide ‘opening’ state into ‘opening-part-1’ and ‘opening-part-2’ states. And implement the async state change. But if we need s two-state opening, it sounds like a CSS design problem to me. Let’s discuss this later if necessary.)
  • In order to gurantee the transition does really end and thus being independent from the ‘animationend’ event(The event here is HTML DOM Event), we need to add a timer between ‘opening’ and ‘opened’ state. Also between ‘closing’ and ‘closed’ state.
  • Let’s call the new event ‘timeout’, the timing we set the timer is right after the transitioning from ‘closed’ to ‘opening’, and from ‘opened’ to ‘closing’ successfully occurs.
  • For some use case we may want to cancel the transition. The ‘cancel’ event is only valid in ‘opening’ and ‘closing’ state.

So far, the state machine for transition we have:

Now the problem is, how to represent this machine in javascript?

My anwser is put every transition relevant functions/attributes in another mixin object, which would be mixed into appWindow.prototype:

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
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
(function(window) {
  'use strict';

  function capitalize(string)
  {
      return string.charAt(0).toUpperCase() + string.slice(1);
  };

  /**
   * This object declares all transition event enum:
   *
   * * OPEN
   * * CLOSE
   * * FINISH
   * * END
   * * CANCEL
   *
   * @static
   * @namespace TransitionEvent
   * @type {Object}
   */
  var EVT = {
    OPEN: 0,
    CLOSE: 1,
    FINISH: 2,
    END: 3,
    CANCEL: 4
  };

  var _EVTARRAY = ['OPEN', 'CLOSE', 'FINISH', 'END', 'CANCEL'];

  /**
   * Describe the transition state table.
   *
   * @example
   * var toState = transitionTable[currentState][event];
   *
   * The value "null" indicates that the transition won't happen.
   * 
   * @type {Object}
   */
  var transitionTable = {
              /* OPEN|CLOSE|FINISH|END|CANCEL */
    'closed':  ['opening', null, null, null, null],
    'opened':  [null, 'closing', null, null, null],
    'closing': ['opened', null, 'closed', 'closed', 'opened'],
    'opening': [null, 'closed', 'opened', 'opened', 'closed']
  };

  /**
   * This provides methods and attributes used for transition state handling. It's not meant to
   * be used directly.
   *
   * The finite state machine of transition is working as(being from normal state):
   * 
   * * `closed`  ---*event* **OPEN** ----------------> `opening`
   * * `opening` ---*event* **END/FINISH/CANCEL** ---> `opened`
   * * `opened`  ---*event* **CLOSE** ---------------> `closing`
   * * `closing` ---*event* **END/FINISH/CANCEL** ---> `closed`
   *
   * If you want to reuse this mixin in your object, you need to define these attributes: `this.element`
   *
   * And these method: `this.setVisible()` `this.publish()`
   *
   * The following callback functions are executed only when the transition state are successfully switched:
   * `_onOpen`
   * `_onClose`
   * `_onEnd`
   * `_onFinish`
   * `_onCancel`
   * `_leaveOpened`
   * `_enterOpened`
   * `_leaveClosed`
   * `_enterClosed`
   * `_leaveClosing`
   * `_enterClosing`
   * `_leaveOpening`
   * `_enterOpening`
   *
   * Every callback here is for internal usage and would be executed only once.
   *
   * However you could utilize inner event in other functions.
   * 
   *
   * @mixin WindowTransition
   */
  /**
   * @event AppWindow#_onTransitionOpen
   * @private
   * @memberof AppWindow
   */
  /**
   * @event AppWindow#_onTransitionClose
   * @private
   * @memberof AppWindow
   */
  /**
   * @event AppWindow#_onTransitionEnd
   * @private
   * @memberof AppWindow
   */
  /**
   * @event AppWindow#_onTransitionFinish
   * @private
   * @memberof AppWindow
   */
  /**
   * @event AppWindow#_onTransitionCancel
   * @private
   * @memberof AppWindow
   */

  var WindowTransition = {
    TRANSITION_EVENT: EVT,

    /**
     * _transitionState indicates current transition state of appWindow.
     *
     * @memberOf WindowTransition
     * @default
     * @type {String}
     */
    _transitionState: 'closed',

    /**
     * Record the previous transition state.
     *
     * **Only updated if the state changes successfully.**
     * 
     * @type {String|null}
     * @memberOf WindowTransition
     */
    _previousTransitionState: null,

    /**
     * Handle the transition event.
     * @memberOf WindowTransition
     */
    _transitionHandler: function aw__transitionHandler() {
      this._cancelTransition();
      this._processTransitionEvent(EVT.FINISH);
    },

    _cancelTransition: function aw__cancelTransition() {
      this.element.className.split(/\s+/).forEach(function(className) {
        if (className.indexOf('transition-') >= 0) {
          this.element.classList.remove(className);
        }
      }, this);
    },

    _enterOpening: function aw__enterOpening(from, to, evt) {
      /**
       * @todo set this._unloaded
       */

      this.resize(null, null, true);
      if (this._unloaded) {
        //this.element.style.backgroundImage = 'url(' + this._splash + ')';
      }

      // Turn of visibility once we're entering opening state.
      this.setVisible(true);

      // Make sure the transition is terminated.
      this._openingTransitionTimer = window.setTimeout(function() {
        if (this._previousTransitionState &&
            this._previousTransitionState == from &&
            this._transitionState == to) {
          this._processTransitionEvent(EVT.END);
        }
      }.bind(this), this._transitionTimeout*1.2);

      /**
       * @event AppWindow#appwillopen
       * @memberof AppWindow
       */
      if (from !== 'opened') {
        // Only publish |willopen| event when previous state is "closed".
        this.publish('willopen');
      }
      this.element.classList.add('transition-opening');
      this.element.classList.add(this._transition['open']);
    },

    _enterClosing: function aw__enterClosing(from, to, evt) {
      // Make sure the transition is terminated.
      this._closingTransitionTimer = window.setTimeout(function() {
        if (this._previousTransitionState &&
            this._previousTransitionState == from &&
            this._transitionState == to) {
          this._processTransitionEvent(EVT.END);
        }
      }.bind(this), this._transitionTimeout*1.2);

      /**
       * @event AppWindow#appwillclose
       * @memberof AppWindow
       */

      if (from !== 'opened') {
        // Only publish |willclose| event when previous state is "opened".
        this.publish('willclose');
      }
      this.element.classList.add('transition-closing');
      this.element.classList.add(this._transition['close']);
    },

    _processTransitionEvent: function aw__processTransitionEvent(evt) {
      var to = transitionTable[this._transitionState][evt];

      if (to === null) {
        return;
      }

      var from = this._transitionState;

      this.leaveState(from, to, evt);
      this.onEvent(from, to, evt);
      this.enterState(from, to, evt);

      this._previousTransitionState = from;
      this._transitionState = to;
    },

    enterState: function aw_enterState(from, to, evt) {
      var funcName = '_enter' + capitalize(to.toLowerCase());
      if (typeof(this[funcName]) == 'function') {
        setTimeout(function(){
          this[funcName](from, to, evt);
        }.bind(this), 0);
      } else if (this[funcName] && Array.isArray(this[funcName])) {
        this[funcName].forEach(function(func) {
          setTimeout(function(){
            func(from, to, evt);
          }.bind(this), 0);
        }, this);
      }
    },

    leaveState: function aw_leaveState(from, to, evt) {
      var funcName = '_leave' + capitalize(from.toLowerCase());
      if (typeof(this[funcName]) == 'function') {
        setTimeout(function(){
          this[funcName](from, to, evt);
        }.bind(this), 0);
      } else if (this[funcName] && Array.isArray(this[funcName])) {
        this[funcName].forEach(function(func) {
          setTimeout(function(){
            func(from, to, evt);
          }.bind(this), 0);
        }, this);
      }
    },

    onEvent: function aw_onEvent(from, to, evt) {
      var funcName = '_onTransition' + capitalize(_EVTARRAY[evt].toLowerCase());
      this._invoke(funcName);
    },

    _enterOpened: function aw__enterOpened(from, to, evt) {
      this._cancelTransition();
      if (this._openingTransitionTimer) {
        window.clearTimeout(this._openingTransitionTimer);
        this._openingTransitionTimer = null;
      }
      this.element.classList.add('active');
      /**
       * @event AppWindow#appopen
       * @memberOf AppWindow
       */

      if (from == 'opening') {
        // Only publish |open| event when previous state is "opening".
        this.publish('open');
      }
    },

    _enterClosed: function aw__enterClosed(from, to, evt) {
      this._cancelTransition();
      if (this._closingTransitionTimer) {
        window.clearTimeout(this._closingTransitionTimer);
        this._closingTransitionTimer = null;
      }
      this.element.classList.remove('active');
      this.setVisible(false);

      /**
       * @event AppWindow#appclose
       * @memberof AppWindow
       */
      if (from == 'closing') {
        // Only publish |close| event when previous state is "closing".
        this.publish('close');
      }
    },

    /**
     * Set the transition way of opening or closing transition.
     * @param  {String} type       'open' or 'close'
     * @param  {String} transition The CSS rule name about window transition.
     * @memberOf WindowTransition
     */
    _setTransition: function aw__setTransition(type, transition) {
      if (type != 'open' && type != 'close')
        return;

      this._transition[type] = transition;
    }
  };

  AppWindow.addMixin(WindowTransition);
}(this));
The state machine’s usage and notes
  1. A single app window instance would send ‘open’ and ‘close’ event due to user action: transitionStateMachine.processEvent(‘open’); transitionStateMachine.processEvent(‘close’); Note, app window doesn’t need to know current state of the state machine.
  2. The state machine itself is the one who creates/removes the timer which triggers timeout event. I am also thinking about moving this out of the state machine and do this in another mixin, only have the callback functions provided by the state machine. But I am not sure.
  3. The state machine has some callback for others(other state machine!) listed below:
    • Enter a state successfully.
    • Leave a state successfully.
    • When an event triggers state switch successfully. In all these callback we would get the previous state and current state, and the event who triggers them.
  4. The CSS class for real UI closing animation is added in enterClosing and removed in enterClosed. Or else we could do that in leaveOpened and leaveClosing. I have no strong opinion here. Maybe we could define the level of the callback into three here, according to the callback order.
  5. The CSS class for real UI opening animation is added in enterOpening and removed in enterOpened.
  6. We could also move out (4) and (5) to another mixin to purify the state machine.

Finally, back to the problems addressed in Tim’s article:

  • Do we need intermediate state?
    • I don’t think so, at least for now. If we really need to goto next state when we successfully from state A to state B, we could call _processEvent again in the inner callback of the state machine. This doesn’t violate the policy that only state machine ifself could decide its next state.
    • If the intermediate state needs to acquire other type of state — just fetch the current state of the other state machine. Or, if we need, register an one-time callback if the current state doesn’t meet our requirement.
  • How about state conflict between two apps?
    • That won’t happen if we deal with the state changes correctly and independently in each app’s scope. I hope so.

Comments