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(); }