fix(nav): swipe to go back gesture

- smoother by debouncing touch events (reduces bank)
- dynamic animation duration
- intelligent behavior based in the position, speed and direccion of the swipe (sharing logic with sliding item)

fixes #8919
fixes #8958
fixes #7934
This commit is contained in:
Manu Mtz.-Almeida
2016-11-01 19:38:27 +01:00
committed by Adam Bradley
parent 033e1eae17
commit 04d61ee47a
20 changed files with 274 additions and 149 deletions

View File

@ -861,9 +861,32 @@ export class Animation {
* Start the animation with a user controlled progress. * Start the animation with a user controlled progress.
*/ */
progressStart() { progressStart() {
// ensure all past transition end events have been cleared
this._clearAsync();
// fire off all the "before" function that have DOM READS in them
// elements will be in the DOM, however visibily hidden
// so we can read their dimensions if need be
// ******** DOM READ ****************
this._beforeReadFn();
// fire off all the "before" function that have DOM WRITES in them
// ******** DOM WRITE ****************
this._beforeWriteFn();
// ******** DOM WRITE ****************
this._progressStart();
}
/**
* @private
* DOM WRITE
* RECURSION
*/
_progressStart() {
for (var i = 0; i < this._cL; i++) { for (var i = 0; i < this._cL; i++) {
// ******** DOM WRITE **************** // ******** DOM WRITE ****************
this._c[i].progressStart(); this._c[i]._progressStart();
} }
// ******** DOM WRITE **************** // ******** DOM WRITE ****************
@ -907,13 +930,14 @@ export class Animation {
/** /**
* End the progress animation. * End the progress animation.
*/ */
progressEnd(shouldComplete: boolean, currentStepValue: number) { progressEnd(shouldComplete: boolean, currentStepValue: number, maxDelta: number = 0) {
console.debug('Animation, progressEnd, shouldComplete', shouldComplete, 'currentStepValue', currentStepValue); console.debug('Animation, progressEnd, shouldComplete', shouldComplete, 'currentStepValue', currentStepValue);
this._isAsync = (currentStepValue > 0.05 && currentStepValue < 0.95); this._isAsync = (currentStepValue > 0.05 && currentStepValue < 0.95);
const dur = 64;
const stepValue = shouldComplete ? 1 : 0; const stepValue = shouldComplete ? 1 : 0;
const factor = Math.max(Math.abs(currentStepValue - stepValue), 0.5) * 2;
const dur = 64 + factor * maxDelta;
this._progressEnd(shouldComplete, stepValue, dur, this._isAsync); this._progressEnd(shouldComplete, stepValue, dur, this._isAsync);
@ -922,7 +946,7 @@ export class Animation {
// set the async TRANSITION END event // set the async TRANSITION END event
// and run onFinishes when the transition ends // and run onFinishes when the transition ends
// ******** DOM WRITE **************** // ******** DOM WRITE ****************
this._asyncEnd(dur, true); this._asyncEnd(dur, shouldComplete);
// this animation has a duration so we need another RAF // this animation has a duration so we need another RAF
// for the CSS TRANSITION properties to kick in // for the CSS TRANSITION properties to kick in

View File

@ -71,7 +71,7 @@ export class App {
// listen for hardware back button events // listen for hardware back button events
// register this back button action with a default priority // register this back button action with a default priority
_platform.registerBackButtonAction(this.navPop.bind(this)); _platform.registerBackButtonAction(this.navPop.bind(this));
this._canDisableScroll = _config.get('canDisableScroll', true); this._canDisableScroll = _config.get('canDisableScroll', false);
} }
/** /**

View File

@ -10,7 +10,7 @@
Menu Menu
</ion-title> </ion-title>
<button ion-button menuToggle="right" right color="secondary"> <button ion-button menuToggle="right" right color="danger">
<ion-icon name="menu"></ion-icon> <ion-icon name="menu"></ion-icon>
</button> </button>

View File

@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, ContentChildren, ContentChild, Dire
import { CSS, nativeRaf, nativeTimeout, clearNativeTimeout } from '../../util/dom'; import { CSS, nativeRaf, nativeTimeout, clearNativeTimeout } from '../../util/dom';
import { Item } from './item'; import { Item } from './item';
import { isPresent, assert } from '../../util/util'; import { isPresent, swipeShouldReset, assert } from '../../util/util';
import { List } from '../list/list'; import { List } from '../list/list';
const SWIPE_MARGIN = 30; const SWIPE_MARGIN = 30;
@ -320,10 +320,10 @@ export class ItemSliding {
// Check if the drag didn't clear the buttons mid-point // Check if the drag didn't clear the buttons mid-point
// and we aren't moving fast enough to swipe open // and we aren't moving fast enough to swipe open
let isCloseDirection = (this._openAmount > 0) === !(velocity < 0); let isResetDirection = (this._openAmount > 0) === !(velocity < 0);
let isMovingFast = Math.abs(velocity) > 0.3; let isMovingFast = Math.abs(velocity) > 0.3;
let isOnCloseZone = Math.abs(this._openAmount) < Math.abs(restingPoint / 2); let isOnCloseZone = Math.abs(this._openAmount) < Math.abs(restingPoint / 2);
if (shouldClose(isCloseDirection, isMovingFast, isOnCloseZone)) { if (swipeShouldReset(isResetDirection, isMovingFast, isOnCloseZone)) {
restingPoint = 0; restingPoint = 0;
} }
@ -463,22 +463,3 @@ export class ItemSliding {
this._renderer.setElementClass(this._elementRef.nativeElement, cssClass, shouldAdd); this._renderer.setElementClass(this._elementRef.nativeElement, cssClass, shouldAdd);
} }
} }
function shouldClose(isCloseDirection: boolean, isMovingFast: boolean, isOnCloseZone: boolean): boolean {
// The logic required to know when the sliding item should close (openAmount=0)
// depends on three booleans (isCloseDirection, isMovingFast, isOnCloseZone)
// and it ended up being too complicated to be written manually without errors
// so the truth table is attached below: (0=false, 1=true)
// isCloseDirection | isMovingFast | isOnCloseZone || shouldClose
// 0 | 0 | 0 || 0
// 0 | 0 | 1 || 1
// 0 | 1 | 0 || 0
// 0 | 1 | 1 || 0
// 1 | 0 | 0 || 0
// 1 | 0 | 1 || 1
// 1 | 1 | 0 || 1
// 1 | 1 | 1 || 1
// The resulting expression was generated by resolving the K-map (Karnaugh map):
let shouldClose = (!isMovingFast && isOnCloseZone) || (isCloseDirection && isMovingFast);
return shouldClose;
}

View File

@ -132,7 +132,6 @@ export class MenuController {
} }
return menu.open(); return menu.open();
} }
return Promise.resolve(false); return Promise.resolve(false);
} }

View File

@ -3,6 +3,7 @@ import { SlideEdgeGesture } from '../../gestures/slide-edge-gesture';
import { SlideData } from '../../gestures/slide-gesture'; import { SlideData } from '../../gestures/slide-gesture';
import { assign } from '../../util/util'; import { assign } from '../../util/util';
import { GestureController, GesturePriority } from '../../gestures/gesture-controller'; import { GestureController, GesturePriority } from '../../gestures/gesture-controller';
import { NativeRafDebouncer } from '../../util/debouncer';
/** /**
* Gesture attached to the content which the menu is assigned to * Gesture attached to the content which the menu is assigned to
@ -11,8 +12,8 @@ export class MenuContentGesture extends SlideEdgeGesture {
constructor( constructor(
public menu: Menu, public menu: Menu,
gestureCtrl: GestureController,
contentEle: HTMLElement, contentEle: HTMLElement,
gestureCtrl: GestureController,
options: any = {}) { options: any = {}) {
super(contentEle, assign({ super(contentEle, assign({
direction: 'x', direction: 'x',
@ -20,6 +21,7 @@ export class MenuContentGesture extends SlideEdgeGesture {
threshold: 0, threshold: 0,
maxEdgeStart: menu.maxEdgeStart || 50, maxEdgeStart: menu.maxEdgeStart || 50,
maxAngle: 40, maxAngle: 40,
debouncer: new NativeRafDebouncer(),
gesture: gestureCtrl.create('menu-swipe', { gesture: gestureCtrl.create('menu-swipe', {
priority: GesturePriority.MenuSwipe, priority: GesturePriority.MenuSwipe,
}) })

View File

@ -198,6 +198,7 @@ export class Menu {
private _isPers: boolean = false; private _isPers: boolean = false;
private _init: boolean = false; private _init: boolean = false;
private _events: UIEventManager = new UIEventManager(); private _events: UIEventManager = new UIEventManager();
private _gestureID: number = 0;
/** /**
* @private * @private
@ -303,7 +304,11 @@ export class Menu {
private _keyboard: Keyboard, private _keyboard: Keyboard,
private _zone: NgZone, private _zone: NgZone,
private _gestureCtrl: GestureController private _gestureCtrl: GestureController
) {} ) {
if (_gestureCtrl) {
this._gestureID = _gestureCtrl.newID();
}
}
/** /**
* @private * @private
@ -332,7 +337,7 @@ export class Menu {
this.setElementAttribute('type', this.type); this.setElementAttribute('type', this.type);
// add the gestures // add the gestures
this._cntGesture = new MenuContentGesture(this, this._gestureCtrl, document.body); this._cntGesture = new MenuContentGesture(this, document.body, this._gestureCtrl);
// register listeners if this menu is enabled // register listeners if this menu is enabled
// check if more than one menu is on the same side // check if more than one menu is on the same side
@ -471,7 +476,7 @@ export class Menu {
} }
private _before() { private _before() {
assert(this._isAnimating === false, '_before should be called when we are not animating'); assert(!this._isAnimating, '_before() should not be called while animating');
// this places the menu into the correct location before it animates in // this places the menu into the correct location before it animates in
// this css class doesn't actually kick off any animations // this css class doesn't actually kick off any animations
@ -483,8 +488,7 @@ export class Menu {
} }
private _after(isOpen: boolean) { private _after(isOpen: boolean) {
assert(this._isAnimating === true, '_after should be called when we are animating'); assert(this._isAnimating, '_before() should be called while animating');
// keep opening/closing the menu disabled for a touch more yet // keep opening/closing the menu disabled for a touch more yet
// only add listeners/css if it's enabled and isOpen // only add listeners/css if it's enabled and isOpen
// and only remove listeners/css if it's not open // and only remove listeners/css if it's not open
@ -494,8 +498,10 @@ export class Menu {
this._events.unlistenAll(); this._events.unlistenAll();
if (isOpen) { if (isOpen) {
this._cntEle.classList.add('menu-content-open'); // Disable swipe to go back gesture
this._gestureCtrl.disableGesture('goback-swipe', this._gestureID);
this._cntEle.classList.add('menu-content-open');
let callback = this.onBackdropClick.bind(this); let callback = this.onBackdropClick.bind(this);
this._events.pointerEvents({ this._events.pointerEvents({
element: this._cntEle, element: this._cntEle,
@ -508,6 +514,9 @@ export class Menu {
this.ionOpen.emit(true); this.ionOpen.emit(true);
} else { } else {
// Enable swipe to go back gesture
this._gestureCtrl.enableGesture('goback-swipe', this._gestureID);
this._cntEle.classList.remove('menu-content-open'); this._cntEle.classList.remove('menu-content-open');
this.setElementClass('show-menu', false); this.setElementClass('show-menu', false);
this.backdrop.setElementClass('show-menu', false); this.backdrop.setElementClass('show-menu', false);

View File

@ -147,6 +147,7 @@ export class E2EPage {
</ion-item> </ion-item>
</ion-list> </ion-list>
<button ion-button full (click)="submit()">Submit</button> <button ion-button full (click)="submit()">Submit</button>
<div padding>
<p>ionViewCanEnter ({{called.ionViewCanEnter}})</p> <p>ionViewCanEnter ({{called.ionViewCanEnter}})</p>
<p>ionViewCanLeave ({{called.ionViewCanLeave}})</p> <p>ionViewCanLeave ({{called.ionViewCanLeave}})</p>
<p>ionViewWillLoad ({{called.ionViewWillLoad}})</p> <p>ionViewWillLoad ({{called.ionViewWillLoad}})</p>
@ -155,6 +156,7 @@ export class E2EPage {
<p>ionViewDidEnter ({{called.ionViewDidEnter}})</p> <p>ionViewDidEnter ({{called.ionViewDidEnter}})</p>
<p>ionViewWillLeave ({{called.ionViewWillLeave}})</p> <p>ionViewWillLeave ({{called.ionViewWillLeave}})</p>
<p>ionViewDidLeave ({{called.ionViewDidLeave}})</p> <p>ionViewDidLeave ({{called.ionViewDidLeave}})</p>
</div>
</ion-content> </ion-content>
`, `,
providers: [SomeComponentProvider] providers: [SomeComponentProvider]
@ -519,10 +521,12 @@ export class ModalFirstPage {
} }
ionViewWillLeave() { ionViewWillLeave() {
console.log('ModalFirstPage ionViewWillLeave fired');
this.called.ionViewWillLeave++; this.called.ionViewWillLeave++;
} }
ionViewDidLeave() { ionViewDidLeave() {
console.log('ModalFirstPage ionViewDidLeave fired');
this.called.ionViewDidLeave++; this.called.ionViewDidLeave++;
} }
@ -626,7 +630,8 @@ export class E2EApp {
], ],
imports: [ imports: [
IonicModule.forRoot(E2EApp, { IonicModule.forRoot(E2EApp, {
statusbarPadding: true statusbarPadding: true,
swipeBackEnabled: true
}) })
], ],
bootstrap: [IonicApp], bootstrap: [IonicApp],

View File

@ -3,7 +3,7 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { clamp, isNumber, isPresent, isString, isTrueProperty } from '../../util/util'; import { clamp, isNumber, isPresent, isString, isTrueProperty } from '../../util/util';
import { Config } from '../../config/config'; import { Config } from '../../config/config';
import { Debouncer } from '../../util/debouncer'; import { TimeoutDebouncer } from '../../util/debouncer';
import { Form } from '../../util/form'; import { Form } from '../../util/form';
import { Ion } from '../ion'; import { Ion } from '../ion';
import { Item } from '../item/item'; import { Item } from '../item/item';
@ -217,7 +217,7 @@ export class Range extends Ion implements AfterViewInit, ControlValueAccessor, O
_step: number = 1; _step: number = 1;
_snaps: boolean = false; _snaps: boolean = false;
_debouncer: Debouncer = new Debouncer(0); _debouncer: TimeoutDebouncer = new TimeoutDebouncer(0);
_events: UIEventManager = new UIEventManager(); _events: UIEventManager = new UIEventManager();
/** /**
* @private * @private

View File

@ -4,7 +4,7 @@ import { NgControl } from '@angular/forms';
import { Config } from '../../config/config'; import { Config } from '../../config/config';
import { Ion } from '../ion'; import { Ion } from '../ion';
import { isPresent, isTrueProperty } from '../../util/util'; import { isPresent, isTrueProperty } from '../../util/util';
import { Debouncer } from '../../util/debouncer'; import { TimeoutDebouncer } from '../../util/debouncer';
/** /**
@ -61,7 +61,7 @@ export class Searchbar extends Ion {
_autocomplete: string = 'off'; _autocomplete: string = 'off';
_autocorrect: string = 'off'; _autocorrect: string = 'off';
_isActive: boolean = false; _isActive: boolean = false;
_debouncer: Debouncer = new Debouncer(250); _debouncer: TimeoutDebouncer = new TimeoutDebouncer(250);
/** /**
* @input {string} The predefined color to use. For example: `"primary"`, `"secondary"`, `"danger"`. * @input {string} The predefined color to use. For example: `"primary"`, `"secondary"`, `"danger"`.

View File

@ -134,6 +134,12 @@ export class Tab1Page1 {
templateUrl: './tab1page2.html' templateUrl: './tab1page2.html'
}) })
export class Tab1Page2 { export class Tab1Page2 {
constructor(public tabs: Tabs) { }
favoritesTab() {
// TODO fix this with tabsHideOnSubPages=true
this.tabs.select(1);
}
ionViewWillEnter() { ionViewWillEnter() {
console.log('Tab1Page2, ionViewWillEnter'); console.log('Tab1Page2, ionViewWillEnter');
@ -346,7 +352,7 @@ export const deepLinkConfig: DeepLinkConfig = {
Tab3Page1 Tab3Page1
], ],
imports: [ imports: [
IonicModule.forRoot(E2EApp, null, deepLinkConfig) IonicModule.forRoot(E2EApp, {tabsHideOnSubPages: true}, deepLinkConfig)
], ],
bootstrap: [IonicApp], bootstrap: [IonicApp],
entryComponents: [ entryComponents: [

View File

@ -10,6 +10,7 @@
<ion-content padding> <ion-content padding>
<p><button ion-button navPush="Tab1Page3">Go to Tab 1, Page 3</button></p> <p><button ion-button navPush="Tab1Page3">Go to Tab 1, Page 3</button></p>
<p><button ion-button (click)="favoritesTab()">Favorites Tab</button></p>
<p><button ion-button class="e2eBackToTab1Page1" navPop>Back to Tab 1, Page 1</button></p> <p><button ion-button class="e2eBackToTab1Page1" navPop>Back to Tab 1, Page 1</button></p>
<div f></div><div f></div><div f></div><div f></div><div f></div><div f></div><div f></div><div f></div><div f></div><div f></div> <div f></div><div f></div><div f></div><div f></div><div f></div><div f></div><div f></div><div f></div><div f></div><div f></div>
<div f></div><div f></div><div f></div><div f></div><div f></div><div f></div><div f></div><div f></div><div f></div><div f></div> <div f></div><div f></div><div f></div><div f></div><div f></div><div f></div><div f></div><div f></div><div f></div><div f></div>

View File

@ -1,8 +1,9 @@
import { defaults } from '../util/util'; import { defaults } from '../util/util';
import { GestureDelegate } from '../gestures/gesture-controller'; import { GestureDelegate } from '../gestures/gesture-controller';
import { PanRecognizer } from './recognizers'; import { PanRecognizer } from './recognizers';
import { PointerEvents, UIEventManager } from '../util/ui-event-manager'; import { PointerEvents, PointerEventsConfig, UIEventManager } from '../util/ui-event-manager';
import { pointerCoord } from '../util/dom'; import { pointerCoord } from '../util/dom';
import { Debouncer, FakeDebouncer } from '../util/debouncer';
/** /**
* @private * @private
@ -12,12 +13,16 @@ export interface PanGestureConfig {
maxAngle?: number; maxAngle?: number;
direction?: 'x' | 'y'; direction?: 'x' | 'y';
gesture?: GestureDelegate; gesture?: GestureDelegate;
debouncer?: Debouncer;
zone?: boolean;
capture?: boolean;
} }
/** /**
* @private * @private
*/ */
export class PanGesture { export class PanGesture {
private debouncer: Debouncer;
private events: UIEventManager = new UIEventManager(false); private events: UIEventManager = new UIEventManager(false);
private pointerEvents: PointerEvents; private pointerEvents: PointerEvents;
private detector: PanRecognizer; private detector: PanRecognizer;
@ -26,31 +31,45 @@ export class PanGesture {
public isListening: boolean = false; public isListening: boolean = false;
protected gestute: GestureDelegate; protected gestute: GestureDelegate;
protected direction: string; protected direction: string;
private eventsConfig: PointerEventsConfig;
constructor(private element: HTMLElement, opts: PanGestureConfig = {}) { constructor(private element: HTMLElement, opts: PanGestureConfig = {}) {
defaults(opts, { defaults(opts, {
threshold: 20, threshold: 20,
maxAngle: 40, maxAngle: 40,
direction: 'x' direction: 'x',
zone: true,
capture: false,
}); });
this.debouncer = (opts.debouncer)
? opts.debouncer
: new FakeDebouncer();
this.gestute = opts.gesture; this.gestute = opts.gesture;
this.direction = opts.direction; this.direction = opts.direction;
this.detector = new PanRecognizer(opts.direction, opts.threshold, opts.maxAngle); this.eventsConfig = {
}
listen() {
if (!this.isListening) {
this.pointerEvents = this.events.pointerEvents({
element: this.element, element: this.element,
pointerDown: this.pointerDown.bind(this), pointerDown: this.pointerDown.bind(this),
pointerMove: this.pointerMove.bind(this), pointerMove: this.pointerMove.bind(this),
pointerUp: this.pointerUp.bind(this), pointerUp: this.pointerUp.bind(this),
}); zone: opts.zone,
this.isListening = true; capture: opts.capture
};
this.detector = new PanRecognizer(opts.direction, opts.threshold, opts.maxAngle);
} }
listen() {
if (this.isListening) {
return;
}
this.pointerEvents = this.events.pointerEvents(this.eventsConfig);
this.isListening = true;
} }
unlisten() { unlisten() {
if (!this.isListening) {
return;
}
this.gestute && this.gestute.release(); this.gestute && this.gestute.release();
this.events.unlistenAll(); this.events.unlistenAll();
this.isListening = false; this.isListening = false;
@ -58,6 +77,7 @@ export class PanGesture {
destroy() { destroy() {
this.gestute && this.gestute.destroy(); this.gestute && this.gestute.destroy();
this.gestute = null;
this.unlisten(); this.unlisten();
this.element = null; this.element = null;
} }
@ -86,6 +106,7 @@ export class PanGesture {
} }
pointerMove(ev: any) { pointerMove(ev: any) {
this.debouncer.debounce(() => {
if (!this.started) { if (!this.started) {
return; return;
} }
@ -109,9 +130,12 @@ export class PanGesture {
this.pointerEvents.stop(); this.pointerEvents.stop();
this.notCaptured(ev); this.notCaptured(ev);
} }
});
} }
pointerUp(ev: any) { pointerUp(ev: any) {
this.debouncer.cancel();
if (!this.started) { if (!this.started) {
return; return;
} }

View File

@ -178,10 +178,10 @@ export class NavControllerBase extends Ion implements NavController {
// transition has successfully resolved // transition has successfully resolved
this._trnsId = null; this._trnsId = null;
resolve && resolve(hasCompleted, isAsync, enteringName, leavingName, direction); resolve && resolve(hasCompleted, isAsync, enteringName, leavingName, direction);
this._sbCheck();
// let's see if there's another to kick off // let's see if there's another to kick off
this.setTransitioning(false); this.setTransitioning(false);
this._sbCheck();
this._nextTrns(); this._nextTrns();
}; };
@ -204,11 +204,10 @@ export class NavControllerBase extends Ion implements NavController {
this._trnsCtrl.destroy(trns.trnsId); this._trnsCtrl.destroy(trns.trnsId);
} }
this._sbCheck();
reject && reject(false, false, rejectReason); reject && reject(false, false, rejectReason);
this.setTransitioning(false); this.setTransitioning(false);
this._sbCheck();
this._nextTrns(); this._nextTrns();
}; };
@ -371,11 +370,14 @@ export class NavControllerBase extends Ion implements NavController {
// and there is not a view that needs to visually transition out // and there is not a view that needs to visually transition out
// then just destroy them and don't transition anything // then just destroy them and don't transition anything
// batch all of lifecycles together // batch all of lifecycles together
// let's make sure, callbacks are zoned
this._zone.run(() => {
for (view of destroyQueue) { for (view of destroyQueue) {
this._willLeave(view); this._willLeave(view);
this._didLeave(view); this._didLeave(view);
this._willUnload(view); this._willUnload(view);
} }
});
// once all lifecycle events has been delivered, we can safely detroy the views // once all lifecycle events has been delivered, we can safely detroy the views
for (view of destroyQueue) { for (view of destroyQueue) {
@ -445,7 +447,7 @@ export class NavControllerBase extends Ion implements NavController {
// successfully finished loading the entering view // successfully finished loading the entering view
// fire off the "didLoad" lifecycle events // fire off the "didLoad" lifecycle events
this._didLoad(view); this._zone.run(this._didLoad.bind(this, view));
} }
_viewTest(enteringView: ViewController, leavingView: ViewController, ti: TransitionInstruction) { _viewTest(enteringView: ViewController, leavingView: ViewController, ti: TransitionInstruction) {
@ -652,6 +654,10 @@ export class NavControllerBase extends Ion implements NavController {
} }
this._cleanup(transition.enteringView); this._cleanup(transition.enteringView);
} else {
// If transition does not complete, we have to cleanup anyway, because
// previous pages in the stack are not hidden probably.
this._cleanup(transition.leavingView);
} }
if (transition.isRoot()) { if (transition.isRoot()) {
@ -761,12 +767,14 @@ export class NavControllerBase extends Ion implements NavController {
_willLoad(view: ViewController) { _willLoad(view: ViewController) {
assert(this.isTransitioning(), 'nav controller should be transitioning'); assert(this.isTransitioning(), 'nav controller should be transitioning');
assert(NgZone.isInAngularZone(), 'callback should be zoned');
view._willLoad(); view._willLoad();
} }
_didLoad(view: ViewController) { _didLoad(view: ViewController) {
assert(this.isTransitioning(), 'nav controller should be transitioning'); assert(this.isTransitioning(), 'nav controller should be transitioning');
assert(NgZone.isInAngularZone(), 'callback should be zoned');
view._didLoad(); view._didLoad();
this.viewDidLoad.emit(view); this.viewDidLoad.emit(view);
@ -775,6 +783,7 @@ export class NavControllerBase extends Ion implements NavController {
_willEnter(view: ViewController) { _willEnter(view: ViewController) {
assert(this.isTransitioning(), 'nav controller should be transitioning'); assert(this.isTransitioning(), 'nav controller should be transitioning');
assert(NgZone.isInAngularZone(), 'callback should be zoned');
view._willEnter(); view._willEnter();
this.viewWillEnter.emit(view); this.viewWillEnter.emit(view);
@ -783,6 +792,7 @@ export class NavControllerBase extends Ion implements NavController {
_didEnter(view: ViewController) { _didEnter(view: ViewController) {
assert(this.isTransitioning(), 'nav controller should be transitioning'); assert(this.isTransitioning(), 'nav controller should be transitioning');
assert(NgZone.isInAngularZone(), 'callback should be zoned');
view._didEnter(); view._didEnter();
this.viewDidEnter.emit(view); this.viewDidEnter.emit(view);
@ -791,6 +801,7 @@ export class NavControllerBase extends Ion implements NavController {
_willLeave(view: ViewController) { _willLeave(view: ViewController) {
assert(this.isTransitioning(), 'nav controller should be transitioning'); assert(this.isTransitioning(), 'nav controller should be transitioning');
assert(NgZone.isInAngularZone(), 'callback should be zoned');
view._willLeave(); view._willLeave();
this.viewWillLeave.emit(view); this.viewWillLeave.emit(view);
@ -799,6 +810,7 @@ export class NavControllerBase extends Ion implements NavController {
_didLeave(view: ViewController) { _didLeave(view: ViewController) {
assert(this.isTransitioning(), 'nav controller should be transitioning'); assert(this.isTransitioning(), 'nav controller should be transitioning');
assert(NgZone.isInAngularZone(), 'callback should be zoned');
view._didLeave(); view._didLeave();
this.viewDidLeave.emit(view); this.viewDidLeave.emit(view);
@ -807,6 +819,7 @@ export class NavControllerBase extends Ion implements NavController {
_willUnload(view: ViewController) { _willUnload(view: ViewController) {
assert(this.isTransitioning(), 'nav controller should be transitioning'); assert(this.isTransitioning(), 'nav controller should be transitioning');
assert(NgZone.isInAngularZone(), 'callback should be zoned');
view._willUnload(); view._willUnload();
this.viewWillUnload.emit(view); this.viewWillUnload.emit(view);
@ -830,18 +843,19 @@ export class NavControllerBase extends Ion implements NavController {
} }
destroy() { destroy() {
let view; for (var view of this._views) {
for (view of this._views) {
view._willUnload(); view._willUnload();
view._destroy(this._renderer); view._destroy(this._renderer);
} }
// purge stack // purge stack
this._views.length = 0; this._views.length = 0;
// release swipe back gesture and transition
this._sbGesture && this._sbGesture.destroy(); this._sbGesture && this._sbGesture.destroy();
this._sbTrns && this._sbTrns.destroy(); this._sbTrns && this._sbTrns.destroy();
this._sbGesture = this._sbTrns = null; this._sbGesture = this._sbTrns = null;
// Unregister navcontroller
if (this.parent && this.parent.unregisterChildNav) { if (this.parent && this.parent.unregisterChildNav) {
this.parent.unregisterChildNav(this); this.parent.unregisterChildNav(this);
} }
@ -863,7 +877,6 @@ export class NavControllerBase extends Ion implements NavController {
removeCount: 1, removeCount: 1,
opts: opts, opts: opts,
}, null); }, null);
} }
swipeBackProgress(stepValue: number) { swipeBackProgress(stepValue: number) {
@ -880,41 +893,31 @@ export class NavControllerBase extends Ion implements NavController {
swipeBackEnd(shouldComplete: boolean, currentStepValue: number) { swipeBackEnd(shouldComplete: boolean, currentStepValue: number) {
if (this._sbTrns && this._sbGesture) { if (this._sbTrns && this._sbGesture) {
// the swipe back gesture has ended // the swipe back gesture has ended
this._sbTrns.progressEnd(shouldComplete, currentStepValue); this._sbTrns.progressEnd(shouldComplete, currentStepValue, 300);
} }
} }
_sbCheck() { _sbCheck() {
if (this._sbEnabled && !this._isPortal) { if (!this._sbEnabled && this._isPortal) {
// this nav controller can have swipe to go back return;
}
// this nav controller can have swipe to go back
if (!this._sbGesture) { if (!this._sbGesture) {
// create the swipe back gesture if we haven't already // create the swipe back gesture if we haven't already
const opts = { const opts = {
edge: 'left', edge: 'left',
threshold: this._sbThreshold threshold: this._sbThreshold
}; };
this._sbGesture = new SwipeBackGesture(this.getNativeElement(), opts, this, this._gestureCtrl); this._sbGesture = new SwipeBackGesture(this, document.body, this._gestureCtrl, opts);
} }
if (this.canSwipeBack()) { if (this.canSwipeBack()) {
// it is be possible to swipe back
if (!this._sbGesture.isListening) {
this._zone.runOutsideAngular(() => {
// start listening if it's not already
console.debug('swipeBack gesture, listen');
this._sbGesture.listen(); this._sbGesture.listen();
}); } else {
}
} else if (this._sbGesture.isListening) {
// it should not be possible to swipe back
// but the gesture is still listening
console.debug('swipeBack gesture, unlisten');
this._sbGesture.unlisten(); this._sbGesture.unlisten();
} }
} }
}
canSwipeBack(): boolean { canSwipeBack(): boolean {
return (this._sbEnabled && return (this._sbEnabled &&

View File

@ -1,23 +1,27 @@
import { assign } from '../util/util'; import { assign, swipeShouldReset } from '../util/util';
import { GestureController, GesturePriority } from '../gestures/gesture-controller'; import { GestureController, GesturePriority, DisableScroll } from '../gestures/gesture-controller';
import { NavControllerBase } from './nav-controller-base'; import { NavControllerBase } from './nav-controller-base';
import { SlideData } from '../gestures/slide-gesture'; import { SlideData } from '../gestures/slide-gesture';
import { SlideEdgeGesture } from '../gestures/slide-edge-gesture'; import { SlideEdgeGesture } from '../gestures/slide-edge-gesture';
import { NativeRafDebouncer } from '../util/debouncer';
export class SwipeBackGesture extends SlideEdgeGesture { export class SwipeBackGesture extends SlideEdgeGesture {
constructor( constructor(
element: HTMLElement,
options: any,
private _nav: NavControllerBase, private _nav: NavControllerBase,
gestureCtlr: GestureController element: HTMLElement,
gestureCtlr: GestureController,
options: any,
) { ) {
super(element, assign({ super(element, assign({
direction: 'x', direction: 'x',
maxEdgeStart: 75, maxEdgeStart: 75,
zone: false,
threshold: 0,
maxAngle: 40,
debouncer: new NativeRafDebouncer(),
gesture: gestureCtlr.create('goback-swipe', { gesture: gestureCtlr.create('goback-swipe', {
priority: GesturePriority.GoBackSwipe, priority: GesturePriority.GoBackSwipe,
disableScroll: DisableScroll.DuringCapture
}) })
}, options)); }, options));
} }
@ -32,23 +36,25 @@ export class SwipeBackGesture extends SlideEdgeGesture {
); );
} }
onSlideBeforeStart(ev: any) { onSlideBeforeStart(ev: any) {
console.debug('swipeBack, onSlideBeforeStart', ev.type);
this._nav.swipeBackStart(); this._nav.swipeBackStart();
} }
onSlide(slide: SlideData) { onSlide(slide: SlideData, ev: any) {
ev.preventDefault();
ev.stopPropagation();
let stepValue = (slide.distance / slide.max); let stepValue = (slide.distance / slide.max);
console.debug('swipeBack, onSlide, distance', slide.distance, 'max', slide.max, 'stepValue', stepValue);
this._nav.swipeBackProgress(stepValue); this._nav.swipeBackProgress(stepValue);
} }
onSlideEnd(slide: SlideData, ev: any) { onSlideEnd(slide: SlideData, ev: any) {
let shouldComplete = (Math.abs(slide.velocity) > 0.2 || Math.abs(slide.delta) > Math.abs(slide.max) * 0.5); const currentStepValue = (slide.distance / slide.max);
let currentStepValue = (slide.distance / slide.max); const isResetDirecction = slide.velocity < 0;
const isMovingFast = Math.abs(slide.velocity) > 0.4;
const isInResetZone = Math.abs(slide.delta) < Math.abs(slide.max) * 0.5;
const shouldComplete = !swipeShouldReset(isResetDirecction, isMovingFast, isInResetZone);
console.debug('swipeBack, onSlideEnd, shouldComplete', shouldComplete, 'currentStepValue', currentStepValue);
this._nav.swipeBackEnd(shouldComplete, currentStepValue); this._nav.swipeBackEnd(shouldComplete, currentStepValue);
} }
} }

View File

@ -110,7 +110,6 @@ export const PLATFORM_CONFIGS: {[key: string]: PlatformConfig} = {
swipeBackThreshold: 40, swipeBackThreshold: 40,
tapPolyfill: isIOSDevice, tapPolyfill: isIOSDevice,
virtualScrollEventAssist: !(window.indexedDB), virtualScrollEventAssist: !(window.indexedDB),
canDisableScroll: isIOSDevice,
}, },
isMatch(p: Platform) { isMatch(p: Platform) {
return p.isPlatformMatch('ios', ['iphone', 'ipad', 'ipod'], ['windows phone']); return p.isPlatformMatch('ios', ['iphone', 'ipad', 'ipod'], ['windows phone']);

View File

@ -53,7 +53,7 @@ export class MDTransition extends PageTransition {
// leaving content // leaving content
this.duration(opts.duration || 200).easing('cubic-bezier(0.47,0,0.745,0.715)'); this.duration(opts.duration || 200).easing('cubic-bezier(0.47,0,0.745,0.715)');
const leavingPage = new Animation(leavingView.pageRef()); const leavingPage = new Animation(leavingView.pageRef());
this.add(leavingPage.fromTo(TRANSLATEY, CENTER, OFF_BOTTOM).fromTo('opacity', 0.99, 0)); this.add(leavingPage.fromTo(TRANSLATEY, CENTER, OFF_BOTTOM).fromTo('opacity', 1, 0));
} }
} }

View File

@ -1,5 +1,20 @@
export class Debouncer { import { nativeRaf } from './dom';
export interface Debouncer {
debounce(Function);
cancel();
}
export class FakeDebouncer implements Debouncer {
debounce(callback: Function) {
callback();
}
cancel() {}
}
export class TimeoutDebouncer implements Debouncer {
private timer: number = null; private timer: number = null;
callback: Function; callback: Function;
@ -11,10 +26,7 @@ export class Debouncer {
} }
schedule() { schedule() {
if (this.timer) { this.cancel();
clearTimeout(this.timer);
this.timer = null;
}
if (this.wait <= 0) { if (this.wait <= 0) {
this.callback(); this.callback();
} else { } else {
@ -22,4 +34,44 @@ export class Debouncer {
} }
} }
cancel() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
} }
}
}
export class NativeRafDebouncer implements Debouncer {
callback: Function = null;
fireFunc: Function;
ptr: number = null;
constructor() {
this.fireFunc = this.fire.bind(this);
}
debounce(callback: Function) {
if (this.callback === null) {
this.callback = callback;
this.ptr = nativeRaf(this.fireFunc);
}
}
fire() {
this.callback();
this.callback = null;
this.ptr = null;
}
cancel() {
if (this.ptr !== null) {
cancelAnimationFrame(this.ptr);
this.ptr = null;
this.callback = null;
}
}
}

View File

@ -72,15 +72,7 @@ export const mockTrasitionController = function(config: Config) {
}; };
export const mockZone = function(): NgZone { export const mockZone = function(): NgZone {
let zone: any = { return new NgZone(false);
run: function(cb: any) {
cb();
},
runOutsideAngular: function(cb: any) {
cb();
}
};
return zone;
}; };
export const mockChangeDetectorRef = function(): ChangeDetectorRef { export const mockChangeDetectorRef = function(): ChangeDetectorRef {

View File

@ -156,6 +156,28 @@ export function reorderArray(array: any[], indexes: {from: number, to: number}):
return array; return array;
} }
/**
* @private
*/
export function swipeShouldReset(isResetDirection: boolean, isMovingFast: boolean, isOnResetZone: boolean): boolean {
// The logic required to know when the sliding item should close (openAmount=0)
// depends on three booleans (isCloseDirection, isMovingFast, isOnCloseZone)
// and it ended up being too complicated to be written manually without errors
// so the truth table is attached below: (0=false, 1=true)
// isCloseDirection | isMovingFast | isOnCloseZone || shouldClose
// 0 | 0 | 0 || 0
// 0 | 0 | 1 || 1
// 0 | 1 | 0 || 0
// 0 | 1 | 1 || 0
// 1 | 0 | 0 || 0
// 1 | 0 | 1 || 1
// 1 | 1 | 0 || 1
// 1 | 1 | 1 || 1
// The resulting expression was generated by resolving the K-map (Karnaugh map):
let shouldClose = (!isMovingFast && isOnResetZone) || (isResetDirection && isMovingFast);
return shouldClose;
}
const ASSERT_ENABLED = true; const ASSERT_ENABLED = true;
/** /**