DB-20: swipe to close menu

This commit is contained in:
Jason Wall 2021-03-03 16:24:51 +00:00
parent 96e380af78
commit 24bb1c98c5
3 changed files with 337 additions and 7 deletions

View File

@ -1,10 +1,11 @@
<body>
<mat-sidenav-container class="sidenav-container">
<mat-sidenav-container class="sidenav-container" [@.disabled]="disableAnimation">
<mat-sidenav
#drawer
fixedInViewport
mode="over"
class="sidenav"
id="drawer"
[attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
[opened]="false"
>
@ -46,6 +47,7 @@
fixedInViewport
mode="over"
class="sidenav"
id="settings"
[attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
position="end"
[opened]="false"

View File

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

View File

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