mirror of
https://gitlab.com/walljm/dynamicbible.git
synced 2025-07-23 07:19:50 -04:00
DB-20: swipe to close menu
This commit is contained in:
parent
96e380af78
commit
24bb1c98c5
@ -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"
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user