diff --git a/src/src/app/app.component.html b/src/src/app/app.component.html
index b92d86f1..0ed37e19 100644
--- a/src/src/app/app.component.html
+++ b/src/src/app/app.component.html
@@ -1,10 +1,11 @@
-
+
@@ -46,6 +47,7 @@
fixedInViewport
mode="over"
class="sidenav"
+ id="settings"
[attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
position="end"
[opened]="false"
diff --git a/src/src/app/app.component.ts b/src/src/app/app.component.ts
index af944eb9..71c37a97 100644
--- a/src/src/app/app.component.ts
+++ b/src/src/app/app.component.ts
@@ -1,4 +1,4 @@
-import { Component, ViewChild, AfterViewInit } from '@angular/core';
+import { Component, ViewChild, AfterViewInit, OnDestroy, ChangeDetectorRef } from '@angular/core';
import { AppService } from './services/app.service';
import { NavService } from './services/nav.service';
import { StorageService } from './services/storage.service';
@@ -16,7 +16,7 @@ import { MatDialog } from '@angular/material/dialog';
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
})
-export class AppComponent extends SubscriberBase implements AfterViewInit {
+export class AppComponent extends SubscriberBase implements AfterViewInit, OnDestroy {
savedPages$ = this.appService.select((state) => (state.savedPages === null ? null : state.savedPages.value));
fontSize$ = this.appService.select((state) => state.settings.value.displaySettings.cardFontSize + 'pt');
cardFont$ = this.appService.select((state) => state.settings.value.displaySettings.cardFontFamily);
@@ -27,15 +27,18 @@ export class AppComponent extends SubscriberBase implements AfterViewInit {
shareReplay()
);
+ disableAnimation = false;
+
@ViewChild('drawer') public sidenav: MatSidenav;
@ViewChild('settings') public settings: MatSidenav;
constructor(
private appService: AppService,
- private navService: NavService,
private storageService: StorageService,
private breakpointObserver: BreakpointObserver,
private snackBar: MatSnackBar,
+ private cd: ChangeDetectorRef,
+ public navService: NavService,
public dialog: MatDialog
) {
super();
@@ -92,6 +95,16 @@ export class AppComponent extends SubscriberBase implements AfterViewInit {
}
ngAfterViewInit(): void {
- this.navService.setSidenav(this.sidenav, this.settings);
+ this.navService.afterViewInit(this.sidenav, this.settings, this.cd);
+
+ this.addSubscription(
+ this.navService.swipe.disableAnimation$.subscribe((v) => {
+ this.disableAnimation = v;
+ })
+ );
+ }
+
+ ngOnDestroy() {
+ this.navService.onDestroy();
}
}
diff --git a/src/src/app/services/nav.service.ts b/src/src/app/services/nav.service.ts
index facc4cc1..80fbbec3 100644
--- a/src/src/app/services/nav.service.ts
+++ b/src/src/app/services/nav.service.ts
@@ -1,16 +1,330 @@
-import { Injectable } from '@angular/core';
+import { ChangeDetectorRef, Injectable } from '@angular/core';
import { MatSidenav } from '@angular/material/sidenav';
+import { BehaviorSubject } from 'rxjs';
+
+export class Swipe {
+ public disableAnimation$ = new BehaviorSubject(false);
+
+ private backdropEl: HTMLElement;
+ private leftDrawerEl: HTMLElement;
+ private rightDrawerEl: HTMLElement;
+
+ private touchStartHandler = this.bodyTouchStart.bind(this);
+ private touchMoveHandler = this.bodyTouchMove.bind(this);
+ private touchEndHandler = this.bodyTouchEnd.bind(this);
+ private transitionEndHandler = this.resetDrawer.bind(this);
+
+ private isIosDevice =
+ typeof window !== 'undefined' &&
+ window.navigator &&
+ window.navigator.platform &&
+ (/iP(ad|hone|od)/.test(window.navigator.platform) ||
+ (window.navigator.platform === 'MacIntel' && window.navigator.maxTouchPoints > 1));
+
+ private readonly swipeInfo: SwipeInfo = {
+ x1: 0,
+ y1: 0,
+ x2: 0,
+ y2: 0,
+ scrolling: null,
+ manual: false,
+ };
+
+ constructor(private leftDrawer: MatSidenav, private rightDrawer: MatSidenav, private cd: ChangeDetectorRef) {}
+
+ afterViewInit(leftDrawerId: string, rightDrawerId: string) {
+ this.backdropEl = document.querySelector('.mat-drawer-backdrop');
+ this.leftDrawerEl = document.getElementById(leftDrawerId);
+ this.rightDrawerEl = document.getElementById(rightDrawerId);
+ window.document.body.addEventListener('touchstart', this.touchStartHandler);
+ window.document.body.addEventListener('touchmove', this.touchMoveHandler, { passive: !this.isIosDevice });
+ window.document.body.addEventListener('touchend', this.touchEndHandler);
+ }
+
+ onDestroy() {
+ window.document.body.removeEventListener('touchstart', this.touchStartHandler);
+ window.document.body.removeEventListener('touchmove', this.touchMoveHandler);
+ window.document.body.removeEventListener('touchend', this.touchEndHandler);
+
+ this.leftDrawerEl.removeEventListener('transitionend', this.transitionEndHandler);
+ this.rightDrawerEl.removeEventListener('transitionend', this.transitionEndHandler);
+ }
+
+ private bodyTouchStart(event: TouchEvent) {
+ const t = event.touches[0];
+ this.swipeInfo.x1 = t.pageX;
+ this.swipeInfo.y1 = t.pageY;
+ this.swipeInfo.x2 = 0;
+ this.swipeInfo.y2 = 0;
+ this.swipeInfo.scrolling = null;
+ this.swipeInfo.manual = false;
+ }
+
+ /**
+ * Handles touch move events to detect if the user is attempting to scroll or swipe.
+ * If the user moves the touch more than 5 px vertically then we assume the user is scrolling.
+ * If the user moves the touch more than 5 px horizontally then we assume the user is swiping and disable scrolling.
+ * Touch end cleans up the scroll disabling.
+ */
+ private bodyTouchMove(event: TouchEvent) {
+ if (this.isScrolling(event)) {
+ // if we're scrolling then ignore these events
+ return;
+ }
+
+ let offset = this.swipeInfo.x2 - this.swipeInfo.x1;
+ const side = this.determineSide(offset);
+
+ // the user is swiping
+ // ignore swiping if the menu is not over
+ if (side.drawer.mode !== 'over') {
+ return;
+ }
+
+ // swipe left is -px, right is +px
+ let translate = 0;
+ if (side.drawer.opened) {
+ // if nav is open then offset should be negative
+ if (this.isOpening(offset, side.drawer)) {
+ return;
+ }
+
+ translate = offset;
+ } else {
+ // if nav is closed then offset should be positive
+ if (!this.isOpening(offset, side.drawer)) {
+ return;
+ }
+ // make sure the offset is not greater than sidenav width
+ // to prevent the sidenav from floating off the left side
+
+ if (side.direction === 'left') {
+ offset = offset > side.drawer._width ? side.drawer._width : offset;
+ translate = -side.drawer._width + offset;
+ } else {
+ offset = Math.abs(offset) > side.drawer._width ? -side.drawer._width : offset;
+ translate = side.drawer._width - Math.abs(offset);
+ }
+
+ side.el.style.boxShadow = null;
+ }
+
+ side.el.style.visibility = 'visible';
+ // update translate3d of sidenav by offset so the drawer moves
+ side.el.style.transform = `translate3d(${translate}px, 0, 0)`;
+ // update the opacity of the background so it fades in/out while the drawer moves
+ this.backdropEl.style.visibility = 'visible';
+ this.backdropEl.style.backgroundColor = `rgba(0,0,0,${
+ (0.6 * Math.abs((side.direction === 'left' ? offset : -offset) + (side.drawer.opened ? side.drawer._width : 0))) /
+ side.drawer._width
+ })`;
+
+ // disable backdrop transition while we're dragging to prevent lag
+ this.backdropEl.style.transitionDuration = '0ms';
+ }
+
+ private bodyTouchEnd(event: TouchEvent) {
+ const t = event.changedTouches[0];
+ this.swipeInfo.x2 = t.pageX;
+ this.swipeInfo.y2 = t.pageY;
+
+ const offset = this.swipeInfo.x2 - this.swipeInfo.x1;
+ const side = this.determineSide(offset);
+
+ // decide if we need to hide or show the sidenav
+ if (this.swipeInfo.scrolling === false) {
+ // enable scrolling again
+ window.document.body.classList.remove('lock-scroll');
+ // restore backdrop transition
+ this.backdropEl.style.transitionDuration = null;
+
+ // if the menu is not over then ignore
+ if (side.drawer.mode !== 'over') {
+ return;
+ }
+
+ // if the offset is < 0 and the sidenav is not open then ignore it
+ // if the offset is > 0 and the sideNav is open then ignore it
+ if (
+ (!this.isOpening(offset, side.drawer) && !side.drawer.opened) ||
+ (this.isOpening(offset, side.drawer) && side.drawer.opened)
+ ) {
+ return;
+ }
+
+ // is the offset < 50% of width then ignore and reset position
+ if (Math.abs(this.swipeInfo.x2 - this.swipeInfo.x1) < side.drawer._width * 0.5) {
+ this.backdropEl.style.visibility = null;
+ if (side.drawer.opened) {
+ // reset drawer position
+ side.el.style.transform = 'none';
+ // reset background opacity
+ this.backdropEl.style.backgroundColor = 'rgba(0,0,0,0.6)';
+ } else {
+ // reset drawer position
+ side.el.style.transform = null;
+ // reset background opacity
+ this.backdropEl.style.backgroundColor = null;
+ side.el.style.boxShadow = 'none';
+ }
+ return;
+ }
+
+ // manually close/open the drawer using css and then update the state of the sidenav
+ // if we close/open the sidenav directly it restarts the animation at fully opened/closed causing jank
+ // so we have to fake the animation and then update the sidenav so the state matches
+ this.swipeInfo.manual = true;
+ this.disableAnimation$.next(true);
+ this.cd.markForCheck();
+ // wait for the end of the transition so we can reset anything we hacked to make this work
+ side.el.addEventListener('transitionend', this.transitionEndHandler);
+ // wait one frame for the handler to be established before setting the transition
+ requestAnimationFrame(() => {
+ side.el.style.transition = '400ms cubic-bezier(0.25, 0.8, 0.25, 1)';
+ this.cd.markForCheck();
+ if (side.drawer.opened) {
+ // update translate3d of sidenav so that it animates closed
+ if (side.direction === 'left') {
+ side.el.style.transform = `translate3d(-100%, 0, 0)`;
+ } else {
+ side.el.style.transform = `translate3d(100%, 0, 0)`;
+ }
+ } else {
+ // update the transform on the sidenav so that it animates open
+ side.el.style.transform = `none`;
+ // reset background opacity
+ this.backdropEl.style.backgroundColor = 'rgba(0,0,0,0.6)';
+ }
+ });
+ }
+ }
+
+ private resetDrawer() {
+ const offset = this.swipeInfo.x2 - this.swipeInfo.x1;
+ const side = this.determineSide(offset);
+
+ side.el.removeEventListener('transitionend', this.transitionEndHandler);
+
+ this.backdropEl.style.visibility = null;
+ if (side.drawer.opened) {
+ // make the backdrop hide as if the sidenav is closed
+ this.backdropEl.classList.remove('mat-drawer-shown');
+ // reset the backgroundColor override we set so it will work normally in the future
+ this.backdropEl.style.backgroundColor = null;
+ // reset the transition and transform properties so the sidenav doesn't get confused when it closes
+ side.el.style.transition = null;
+ side.el.style.transform = 'none';
+
+ // update the sidenav state to closed
+ side.drawer.toggle(false);
+ } else {
+ // make the backdrop show as if the sidenav is open
+ this.backdropEl.classList.add('mat-drawer-shown');
+ // reset the backgroundColor override we set so it will work normally in the future
+ this.backdropEl.style.backgroundColor = null;
+ // reset the transition and transform properties so the sidenav doesn't get confused when it closes
+ side.el.style.transition = null;
+
+ side.drawer.toggle(true);
+ }
+
+ this.cd.markForCheck();
+
+ requestAnimationFrame(() => {
+ this.swipeInfo.manual = false;
+ this.disableAnimation$.next(false);
+ this.cd.markForCheck();
+ });
+ }
+
+ private isOpening(offset: number, drawer: MatSidenav) {
+ if (drawer.position === 'end') {
+ return offset < 0; // its on the right not the left, so its inverted.
+ } else {
+ return offset > 0;
+ }
+ }
+
+ private isScrolling(event: TouchEvent) {
+ if (this.swipeInfo.scrolling) {
+ // if we're scrolling then ignore these events
+ return true;
+ }
+
+ const t = event.touches[0];
+ this.swipeInfo.x2 = t.pageX;
+ this.swipeInfo.y2 = t.pageY;
+
+ // check if we have decided if the user is scrolling or not
+ if (this.swipeInfo.scrolling === null) {
+ if (Math.abs(this.swipeInfo.y2 - this.swipeInfo.y1) > 5) {
+ // if the user has moved more than 5 pixels y then they're scrolling
+ this.swipeInfo.scrolling = true;
+ return true;
+ }
+
+ if (Math.abs(this.swipeInfo.x2 - this.swipeInfo.x1) > 5) {
+ // if the user has moved more than 5 pixels x then they're swiping
+ this.swipeInfo.scrolling = false;
+ // disable scrolling
+ window.document.body.classList.add('lock-scroll');
+ if (this.isIosDevice) {
+ // css overflow:hidden doesn't work on the body for iOS so we have to use a non-passive listener and preventdefault to prevent scrolling
+ event.preventDefault();
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private determineSide(offset: number) {
+ if (this.leftDrawer.opened) {
+ return { drawer: this.leftDrawer, el: this.leftDrawerEl, direction: 'left' };
+ }
+
+ if (this.rightDrawer.opened) {
+ return { drawer: this.rightDrawer, el: this.rightDrawerEl, direction: 'right' };
+ }
+ // both are closed, so you must be opening.
+ // return the one from the side you start swiping on.
+
+ if (offset < 0) {
+ // negative indicates swiping left
+ return { drawer: this.rightDrawer, el: this.rightDrawerEl, direction: 'right' }; // swiping left means starting right, so return the right drawer.
+ }
+
+ return { drawer: this.leftDrawer, el: this.leftDrawerEl, direction: 'left' };
+ }
+}
+
+export interface SwipeInfo {
+ x1: number;
+ y1: number;
+ x2: number;
+ y2: number;
+ scrolling: boolean | null;
+ manual: boolean;
+}
@Injectable({
providedIn: 'root',
})
export class NavService {
+ public swipe: Swipe;
+
private sidenav: MatSidenav;
private settings: MatSidenav;
- public setSidenav(sidenav: MatSidenav, settings: MatSidenav) {
+ public afterViewInit(sidenav: MatSidenav, settings: MatSidenav, cd: ChangeDetectorRef) {
this.sidenav = sidenav;
this.settings = settings;
+ this.swipe = new Swipe(sidenav, settings, cd);
+ this.swipe.afterViewInit('drawer', 'settings');
+ }
+
+ public onDestroy() {
+ this.swipe.onDestroy();
}
public openNav() {
@@ -34,6 +348,7 @@ export class NavService {
const r = await this.settings.close();
return r;
}
+
public toggleSettings(): void {
this.settings.toggle();
}