diff --git a/.eslintrc.js b/.eslintrc.js index 4c662f0..8bf3156 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,5 +1,6 @@ module.exports = { root: true, + parser: 'babel-eslint', parserOptions: { ecmaVersion: 2017, sourceType: 'module' diff --git a/addon/components/nav-stack.js b/addon/components/nav-stack.js index ef996f8..1a6ccf3 100644 --- a/addon/components/nav-stack.js +++ b/addon/components/nav-stack.js @@ -1,56 +1,128 @@ -import { readOnly, mapBy, bool } from '@ember/object/computed'; +import { className, classNames, layout } from '@ember-decorators/component'; +import { computed, observes } from '@ember-decorators/object'; import Component from '@ember/component'; -import layout from '../templates/components/nav-stack'; -import { computed, get } from '@ember/object'; -import { inject as service } from '@ember/service'; +import { get } from '@ember/object'; import { run, scheduleOnce } from '@ember/runloop'; -import { observer } from '@ember/object'; - -import { - nextTick, - computeTimeout, - setTransformTranslateStyle -} from 'ember-nav-stack/utils/animation' - -export default Component.extend({ - layer: null, // PT.number.isRequired - footer: null, // componentRef, optional - birdsEyeDebugging: false, // PT.bool, optional - navStacksService: service('nav-stacks'), - layout, - classNames: ['NavStack'], - classNameBindings: ['layerIndexCssClass', 'hasFooter:NavStack--withFooter', 'birdsEyeDebugging:is-birdsEyeDebugging'], - layerIndexCssClass: computed('layer', function() { - return `NavStack--layer${this.get('layer')}`; - }), - headerComponent: readOnly('stackItems.lastObject.headerComponent'), - stackItems: computed('layer', 'navStacksService.stacks', function(){ +import { nextTick } from 'ember-nav-stack/utils/animation'; +import BackSwipeRecognizer from 'ember-nav-stack/utils/back-swipe-recognizer'; +import Hammer from 'hammerjs'; +import template from '../templates/components/nav-stack'; +import { argument } from '@ember-decorators/argument'; +import { optional, type } from '@ember-decorators/argument/type'; +import { ClosureAction } from '@ember-decorators/argument/types'; +import { required } from '@ember-decorators/argument/validation'; +import { service } from '@ember-decorators/service'; +import { bool, mapBy, readOnly } from '@ember-decorators/object/computed'; +import { Spring } from 'wobble'; + +function currentTransitionPercentage(fromValue, toValue, currentValue) { + if (fromValue === undefined || fromValue === toValue) { + return 1; + } + let percentage = Math.abs((currentValue - fromValue) / (toValue - fromValue)); + if (toValue > fromValue) { + return 1 - percentage; + } + return percentage; +} + +function styleHeaderElements(transitionRatio, isForward, currentHeaderElement, otherHeaderElement) { + let startingOffset = 60; + if (!isForward) { + transitionRatio = 1 - transitionRatio; + startingOffset = -1 * startingOffset; + } + let xOffset = transitionRatio * -1 * startingOffset; + if (currentHeaderElement) { + currentHeaderElement.style.opacity = transitionRatio; + currentHeaderElement.style.transform = `translateX(${startingOffset + xOffset}px)`; + } + if (otherHeaderElement) { + otherHeaderElement.style.opacity = 1 - transitionRatio; + otherHeaderElement.style.transform = `translateX(${xOffset}px)`; + } +} + +@layout(template) +@classNames('NavStack') +export default class NavStack extends Component { + @argument @type('number') @required + layer; + + @argument // ComponentRef + footer + + @argument @type(ClosureAction) + back; + + @argument @type(optional('boolean')) + @className('is-birdsEyeDebugging') + birdsEyeDebugging = false; + + @service('nav-stacks') + navStacksService; + + @computed('layer') + @className + get layerIndexCssClass() { + return `NavStack--layer${this.layer}`; + } + @computed('stackItems.@each.headerComponent') + get headerComponent() { + return this.stackItems[this.stackItems.length - 1].headerComponent; + } + + @computed('stackItems.@each.headerComponent') + get parentItemHeaderComponent() { + if (this.stackItems.length < 2) { + return; + } + return this.stackItems[this.stackItems.length - 2].headerComponent; + } + + @computed('layer', 'navStacksService.stacks') + get stackItems(){ return this.get(`navStacksService.stacks.layer${this.get('layer')}`); - }), - stackDepth: readOnly('stackItems.length'), - components: mapBy('stackItems', 'component'), - hasFooter: bool('footer'), - headerTransitionRules, + } + + @readOnly('stackItems.length') + stackDepth; + + @mapBy('stackItems', 'component') + components; + + @bool('footer') + @className('NavStack--withFooter') + hasFooter; + didInsertElement(){ this._super(...arguments); - scheduleOnce('afterRender', this, this.handleStackDepthChange, true); - }, - stackDepthChanged: observer('stackItems', function() { - this.handleStackDepthChange(); - }), - - handleStackDepthChange(initialRender = false) { - let stackDepth = this.get('stackItems.length') || 0; - let rootComponentRef = this.get('stackItems.firstObject.component'); + this.hammer = new Hammer.Manager(this.element, { + recognizers: [ + [BackSwipeRecognizer] + ] + }); + let isInitialRender = this.navStacksService.isInitialRender; + scheduleOnce('afterRender', this, this.handleStackDepthChange, isInitialRender); + } + + @observes('stackItems') + stackItemDidChange() { + this.handleStackDepthChange(false); + } + + handleStackDepthChange(isInitialRender) { + let stackItems = this.stackItems || []; + let stackDepth = stackItems.length; + let rootComponentRef = stackItems[0] && stackItems[0].component; let rootComponentIdentifier = getComponentIdentifier(rootComponentRef); - let headerAnimation = 'cut'; - let layer = this.get('layer'); - if (initialRender) { + let layer = this.layer; + if (isInitialRender) { this.schedule(this.cut); } - else if (layer > 0 && stackDepth > 0 && this._stackDepth === 0) { + else if (layer > 0 && stackDepth > 0 && this._stackDepth === 0 || this._stackDepth === undefined) { this.schedule(this.slideUp); } @@ -64,148 +136,339 @@ export default Component.extend({ } else if (stackDepth < this._stackDepth) { this.cloneLastStackItem(); + this.cloneHeader(); this.schedule(this.slideBack); - headerAnimation = 'slideBack'; } else if (stackDepth > this._stackDepth) { + this.cloneHeader(); this.schedule(this.slideForward); - headerAnimation = 'slideForward'; } - this.setHeaderInfo(headerAnimation); this._stackDepth = stackDepth; this._rootComponentIdentifier = rootComponentIdentifier; - }, - - setHeaderInfo(enterAnimation = 'cut') { - let { stackItems } = this; - let headerComponent; - if (stackItems && stackItems.length >= 1) { - headerComponent = stackItems[stackItems.length - 1].headerComponent; - } - this.set('headerInfo', { - component: headerComponent, - enterAnimation - }); - }, + } schedule(method) { scheduleOnce('afterRender', this, method); - }, + } computeXPosition() { - let stackDepth = this.get('stackDepth'); - let layerX = `${(stackDepth - 1) * -20}%`; + let stackDepth = this.stackDepth; + if (stackDepth === 0) { + return 0; + } + let currentStackItemElement = this.element.querySelector('.NavStack-item:last-child'); + if (!currentStackItemElement) { + return 0; + } + let itemWidth = currentStackItemElement.getBoundingClientRect().width; + + let layerX = (stackDepth - 1) * itemWidth * -1; return layerX; - }, + } cut() { - let { element } = this; - let x = this.computeXPosition(); - let stackItemEl = element.querySelector('.NavStack-itemContainer') - stackItemEl.classList.add('isCutting'); - this.transition(stackItemEl, 'X', x, () => { - stackItemEl.classList.remove('isCutting'); + this.horizontalTransition({ + toValue: this.computeXPosition(), + animate: false }); - if (this.get('layer') > 0 & this.get('stackDepth') > 0) { - element.classList.add('isCutting'); - setTransformTranslateStyle(element, 'Y', '0px'); - this.transition(this.element, 'Y', '0px', () => { - element.classList.remove('isCutting'); + if (this.get('layer') > 0 & this.stackDepth > 0) { + this.verticalTransition({ + element: this.element, + toValue: 0, + animate: false }); } - }, + } slideForward() { - let element = this.element.querySelector('.NavStack-itemContainer') - let x = this.computeXPosition(); - this.transition(element, 'X', x); - }, + this.horizontalTransition({ + toValue: this.computeXPosition(), + finishCallback: () => { + this.removeClonedHeader(); + } + }); + } slideBack() { - let element = this.element.querySelector('.NavStack-itemContainer') - let x = this.computeXPosition(); - - this.transition(element, 'X', x, () => { - if (this._clonedStackItem) { - this._clonedStackItem.parentNode.removeChild(this._clonedStackItem); - this._clonedStackItem = null; + this.horizontalTransition({ + toValue: this.computeXPosition(), + finishCallback: () => { + this.removeClonedStackItem(); + this.removeClonedHeader(); } }); - }, + } slideUp() { - this.transition(this.element, 'Y', '0px'); - }, + let debug = this.get('birdsEyeDebugging'); + this.verticalTransition({ + element: this.element, + toValue: 0, + fromValue: debug ? 480 : this.element.getBoundingClientRect().height + }); + } slideDown() { let debug = this.get('birdsEyeDebugging'); - let y = debug ? '480px' : '100vh'; - this.transition(this._clonedElement, 'Y', y, () => { - if (this._clonedElement) { - this._clonedElement.parentNode.removeChild(this._clonedElement); - this._clonedElement = null; - } + let y = debug ? 480 : this._clonedElement.getBoundingClientRect().height; + nextTick().then(() => { + this.verticalTransition({ + element: this._clonedElement, + toValue: y, + finishCallback: () => { + if (this._clonedElement) { + this._clonedElement.parentNode.removeChild(this._clonedElement); + this._clonedElement = null; + } + } + }); }); - }, + } + + horizontalTransition({ toValue, fromValue, animate=true, finishCallback }) { + let itemContainerElement = this.element.querySelector('.NavStack-itemContainer'); + let currentHeaderElement = this.element.querySelector('.NavStack-currentHeaderContainer'); + let clonedHeaderElement = this.element.querySelector('.NavStack-clonedHeaderContainer'); - transition(element, plane, amount, finishCallback) { this.transitionDidBegin(); - setTransformTranslateStyle(element, plane, amount); + this.notifyTransitionStart(); + let finish = () => { + itemContainerElement.style.transform = `translateX(${toValue}px)`; + styleHeaderElements( + currentTransitionPercentage(fromValue, toValue, toValue), + fromValue === undefined || fromValue > toValue, + currentHeaderElement, + clonedHeaderElement + ); + this.notifyTransitionEnd(); + this.transitionDidEnd(); + if (finishCallback) { + finishCallback(); + } + }; + if (animate) { + fromValue = fromValue || itemContainerElement.getBoundingClientRect().x; + if (fromValue === toValue) { + run(finish); + return; + } + let spring = this._createSpring({ fromValue, toValue }); + spring.onUpdate((s) => { + itemContainerElement.style.transform = `translateX(${s.currentValue}px)`; + styleHeaderElements( + currentTransitionPercentage(fromValue, toValue, s.currentValue), + fromValue > toValue, + currentHeaderElement, + clonedHeaderElement + ); + }).onStop(() => { + run(finish); + }).start(); + return; + } + run(finish); + } - nextTick().then(() => { - run.later(() => { - this.transitionDidEnd(); - if (finishCallback) { - finishCallback(); - } - }, computeTimeout(element) || 0); + verticalTransition({ element, toValue, fromValue, animate=true, finishCallback }) { + this.transitionDidBegin(); + this.notifyTransitionStart(); + let finish = () => { + element.style.transform = `translateY(${toValue}px)`; + this.notifyTransitionEnd(); + this.transitionDidEnd(); + if (finishCallback) { + finishCallback(); + } + }; + if (animate) { + fromValue = fromValue || element.getBoundingClientRect().y; + if (fromValue === toValue) { + run(finish); + return; + } + let spring = this._createSpring({ fromValue, toValue }); + spring.onUpdate((s) => { + element.style.transform = `translateY(${s.currentValue}px)`; + }).onStop(() => { + run(finish); + }).start(); + return; + } + run(finish); + } + + _createSpring({ initialVelocity=0, fromValue, toValue }) { + return new Spring({ + initialVelocity, + fromValue, + toValue, + stiffness: 1000, + damping: 500, + mass: 3 + }); + } + + transitionDidBegin(){} + + transitionDidEnd(){ + if (this._currentStackItemElement) { + this.hammer.off('pan'); + } + if (!this.element || this.get('stackDepth') <= 1) { + return; + } + this._setupPanHandler(); + } + + notifyTransitionStart() { + this.navStacksService.notifyTransitionStart(); + } + + notifyTransitionEnd() { + this.navStacksService.notifyTransitionEnd(); + } + + _setupPanHandler() { + let containerElement = this.element.querySelector('.NavStack-itemContainer'); + let currentHeaderElement = this.element.querySelector('.NavStack-currentHeaderContainer'); + let parentHeaderElement = this.element.querySelector('.NavStack-parentItemHeaderContainer'); + let startingX = containerElement.getBoundingClientRect().x; + let currentStackItemElement = this._currentStackItemElement = this.element.querySelector('.NavStack-item:last-child'); + if (!currentStackItemElement) { + return; + } + let itemWidth = currentStackItemElement.getBoundingClientRect().width; + let backX = containerElement.getBoundingClientRect().x + itemWidth; + let thresholdX = itemWidth / 2; + let canNavigateBack = this.back && this.get('stackDepth') > 1; + this.hammer.on('pan', (ev) => { + containerElement.style.transform = `translateX(${startingX + ev.deltaX}px)`; + styleHeaderElements( + currentTransitionPercentage(startingX, backX, startingX + ev.deltaX), + true, + currentHeaderElement, + parentHeaderElement + ); + + let transitionRatio = currentTransitionPercentage(startingX, backX, startingX + ev.deltaX); + if (currentHeaderElement) { + currentHeaderElement.style.opacity = transitionRatio; + } + if (parentHeaderElement) { + parentHeaderElement.style.opacity = 1 - transitionRatio; + } + if (ev.isFinal) { + let shouldNavigateBack = ev.center.x >= thresholdX && canNavigateBack; + let initialVelocity = ev.velocityX; + let fromValue = startingX + ev.deltaX; + let toValue = shouldNavigateBack ? backX : startingX; + let spring = this._createSpring({ initialVelocity, fromValue, toValue }); + spring.onUpdate((s) => { + containerElement.style.transform = `translateX(${s.currentValue}px)`; + styleHeaderElements( + currentTransitionPercentage(startingX, backX, s.currentValue), + false, + parentHeaderElement, + currentHeaderElement + ); + if (!shouldNavigateBack && s.currentValue >= startingX + thresholdX) { + shouldNavigateBack = true; + spring.updateConfig({ + toValue: backX + }); + } + }).onStop(() => { + if (shouldNavigateBack) { + styleHeaderElements( + currentTransitionPercentage(startingX, backX, backX), + false, + parentHeaderElement, + currentHeaderElement + ); + this.back(); + } else { + containerElement.style.transform = `translateX(${startingX}px)`; + styleHeaderElements( + currentTransitionPercentage(startingX, backX, startingX), + false, + parentHeaderElement, + currentHeaderElement + ); + } + currentHeaderElement.style.opacity = 1; + currentHeaderElement.style.transform = 'translateX(0px)'; + parentHeaderElement.style.opacity = 0; + parentHeaderElement.style.transform = 'translateX(-60px)'; + }).start(); + } }); - }, - transitionDidBegin(){}, - transitionDidEnd(){}, + } cloneLastStackItem() { let clone = this._clonedStackItem = this.element.querySelector('.NavStack-item:last-child').cloneNode(true); clone.setAttribute('id', `${this.elementId}_clonedStackItem`); this.attachClonedStackItem(clone); - }, + } + + cloneHeader() { + this.removeClonedHeader(); + let liveHeader = this.element.querySelector('.NavStack-currentHeaderContainer'); + let clonedHeader = this._clonedHeader = liveHeader.cloneNode(true); + clonedHeader.classList.remove('NavStack-currentHeaderContainer'); + clonedHeader.classList.add('NavStack-clonedHeaderContainer'); + this.attachClonedHeader(clonedHeader); + } + cloneElement() { let clone = this._clonedElement = this.element.cloneNode(true); clone.setAttribute('id', `${this.elementId}_clone`); this.attachClonedElement(clone); - }, + } + attachClonedStackItem(clone) { this.element.querySelector('.NavStack-itemContainer').appendChild(clone); - }, + } + + attachClonedHeader(clone) { + let headerWrapper = this.element.querySelector('.NavStack-header'); + headerWrapper.insertBefore(clone, headerWrapper.firstChild); + } + attachClonedElement(clone) { this.element.parentNode.appendChild(clone); clone.style.transform; // force layout, without this CSS transition does not run } -}); - -function headerTransitionRules() { - this.transition( - this.use('slideTitle', 'left'), - this.toValue(function(newValue) { - return newValue.enterAnimation === 'slideForward'; - }) - ); - - this.transition( - this.use('slideTitle', 'right'), - this.toValue(function(newValue) { - return newValue.enterAnimation === 'slideBack'; - }) - ); + + removeClonedHeader() { + if (this._clonedHeader) { + this._clonedHeader.parentNode.removeChild(this._clonedHeader); + this._clonedHeader = null; + } + } + + removeClonedStackItem() { + if (this._clonedStackItem) { + this._clonedStackItem.parentNode.removeChild(this._clonedStackItem); + this._clonedStackItem = null; + } + } + + preferRecognizer(recognizer) { + this.hammer.get('pan').requireFailure(recognizer); + } + + stopPreferringRecognizer(recognizer) { + this.hammer.get('pan').dropRequireFailure(recognizer); + } } function getComponentIdentifier(componentRef) { if (!componentRef) { return 'none'; } - let result = componentRef.name; + let result = componentRef.name || componentRef.inner.name; if (componentRef.args.named.model) { let model = componentRef.args.named.model.value(); if (model) { diff --git a/addon/components/to-nav-stack.js b/addon/components/to-nav-stack.js index bc98ceb..344a5a8 100644 --- a/addon/components/to-nav-stack.js +++ b/addon/components/to-nav-stack.js @@ -1,22 +1,35 @@ import { guidFor } from '@ember/object/internals'; -import { inject as service } from '@ember/service'; import Component from '@ember/component'; +import { argument } from '@ember-decorators/argument'; +import { type } from '@ember-decorators/argument/type'; +import { required } from '@ember-decorators/argument/validation'; +import { tagName } from '@ember-decorators/component'; +import { service } from '@ember-decorators/service'; + +@tagName('') +export default class ToNavStack extends Component { + @argument @type('number') @required + layer; + + @argument + item = null; + + @argument + header = null; + + @service('nav-stacks') + service; -export default Component.extend({ - layer: null, // PT.number.isRequired - item: null, // component ref - header: null, // component ref - service: service('nav-stacks'), - tagName: '', willRender() { - this.get('service').pushItem( + this.service.pushItem( guidFor(this), this.get('layer'), this.get('item'), this.get('header') ); - }, + } + willDestroyElement() { - this.get('service').removeItem(guidFor(this)); + this.service.removeItem(guidFor(this)); } -}); +} diff --git a/addon/helpers/nav-layer-indices.js b/addon/helpers/nav-layer-indices.js index 3ce32b3..0b214e9 100644 --- a/addon/helpers/nav-layer-indices.js +++ b/addon/helpers/nav-layer-indices.js @@ -1,22 +1,28 @@ import Helper from '@ember/component/helper'; -import { inject as service } from '@ember/service'; -import { observer } from '@ember/object'; -import { computed } from '@ember/object'; +import { computed, observes } from '@ember-decorators/object'; +import { service } from '@ember-decorators/service'; -export default Helper.extend({ - navStacks: service(), - compute: function() { - let layerCount = this.get('layerCount'); +export default class NavLayerIndices extends Helper { + + @service + navStacks; + + compute() { + let layerCount = this.layerCount; let indices = []; for (let i = 0; i < layerCount; i++) { indices.push(i); } return indices; - }, - layerCount: computed('navStacks.stacks', function(){ - return Object.keys(this.get('navStacks.stacks')).length; - }), - navStacksChanged: observer('layerCount', function(){ + } + + @computed('navStacks.stacks') + get layerCount(){ + return Object.keys(this.navStacks.stacks).length; + } + + @observes('layerCount') + navStacksChanged() { this.recompute(); - }) -}); + } +} diff --git a/addon/mixins/stackable-route.js b/addon/mixins/stackable-route.js index 9284252..f442422 100644 --- a/addon/mixins/stackable-route.js +++ b/addon/mixins/stackable-route.js @@ -34,7 +34,7 @@ export default Mixin.create({ setupController(controller, model) { this._super(controller, model); controller.setProperties({ - layerIndex: this.get('layerIndex'), + layerIndex: this.layerIndex, routeComponent: this.getRouteComponent(model), headerComponent: this.getHeaderComponent(model), routeName: this.routeName @@ -46,6 +46,6 @@ export default Mixin.create({ actions: { back() { this.transitionTo(this.getParentRouteName()); - }, + } } }); diff --git a/addon/services/nav-stacks.js b/addon/services/nav-stacks.js index 2eb984c..1a53b17 100644 --- a/addon/services/nav-stacks.js +++ b/addon/services/nav-stacks.js @@ -3,13 +3,15 @@ import Service from '@ember/service'; import { run } from '@ember/runloop'; import EmberObject from '@ember/object'; -export default Service.extend({ - init() { - this._super(); +export default class NavStacks extends Service { + constructor() { + super(...arguments); this.set('stacks', EmberObject.create()); this._itemsById = {}; this._counter = 1; - }, + this._runningTransitions = 0; + this.isInitialRender = true; + } pushItem(sourceId, layer, component, headerComponent) { this._itemsById[sourceId] = { @@ -19,33 +21,47 @@ export default Service.extend({ order: this._counter++ }; this._schedule(); - }, + } removeItem(sourceId) { delete this._itemsById[sourceId]; this._schedule(); - }, + } + + notifyTransitionStart() { + this._runningTransitions++; + } + + notifyTransitionEnd() { + this._runningTransitions--; + } + + runningTransitions() { + return this._runningTransitions; + } _schedule() { run.scheduleOnce('afterRender', this, this._process); - }, + } _process() { let newStacks = {}; let itemsById = this._itemsById; - Object.keys(itemsById).forEach((sourceId) => { + for (var sourceId in itemsById) { let { layer, component, headerComponent, order } = itemsById[sourceId]; let layerName = `layer${layer}`; newStacks[layerName] = newStacks[layerName] || A(); let newItem = component ? { component, headerComponent, order } : null; newStacks[layerName].push(newItem); - }); - Object.keys(newStacks).forEach((layerName) => { + } + for (var layerName in newStacks) { newStacks[layerName] = newStacks[layerName].sortBy('order'); - }); - + } this.set('stacks', EmberObject.create(newStacks)); + if (this.isInitialRender === true) { + run.next(this, this.set, 'isInitialRender', false); + } } -}); +} diff --git a/addon/templates/components/nav-stack.hbs b/addon/templates/components/nav-stack.hbs index 8d3b038..49d6721 100644 --- a/addon/templates/components/nav-stack.hbs +++ b/addon/templates/components/nav-stack.hbs @@ -9,9 +9,14 @@ {{/each}}