mirror of
https://gitlab.com/walljm/dynamicbible.git
synced 2025-07-23 15:30:14 -04:00
Merge branch 'db-20-swipe-to-close-menu' into 'main'
DB-20: swipe to close menu See merge request walljm/dynamicbible!13
This commit is contained in:
commit
776fc9b2cd
@ -1,10 +1,11 @@
|
|||||||
<body>
|
<body>
|
||||||
<mat-sidenav-container class="sidenav-container">
|
<mat-sidenav-container class="sidenav-container" [@.disabled]="disableAnimation">
|
||||||
<mat-sidenav
|
<mat-sidenav
|
||||||
#drawer
|
#drawer
|
||||||
fixedInViewport
|
fixedInViewport
|
||||||
mode="over"
|
mode="over"
|
||||||
class="sidenav"
|
class="sidenav"
|
||||||
|
id="drawer"
|
||||||
[attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
|
[attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
|
||||||
[opened]="false"
|
[opened]="false"
|
||||||
>
|
>
|
||||||
@ -46,6 +47,7 @@
|
|||||||
fixedInViewport
|
fixedInViewport
|
||||||
mode="over"
|
mode="over"
|
||||||
class="sidenav"
|
class="sidenav"
|
||||||
|
id="settings"
|
||||||
[attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
|
[attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
|
||||||
position="end"
|
position="end"
|
||||||
[opened]="false"
|
[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 { AppService } from './services/app.service';
|
||||||
import { NavService } from './services/nav.service';
|
import { NavService } from './services/nav.service';
|
||||||
import { StorageService } from './services/storage.service';
|
import { StorageService } from './services/storage.service';
|
||||||
@ -16,7 +16,7 @@ import { MatDialog } from '@angular/material/dialog';
|
|||||||
templateUrl: './app.component.html',
|
templateUrl: './app.component.html',
|
||||||
styleUrls: ['./app.component.scss'],
|
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));
|
savedPages$ = this.appService.select((state) => (state.savedPages === null ? null : state.savedPages.value));
|
||||||
fontSize$ = this.appService.select((state) => state.settings.value.displaySettings.cardFontSize + 'pt');
|
fontSize$ = this.appService.select((state) => state.settings.value.displaySettings.cardFontSize + 'pt');
|
||||||
cardFont$ = this.appService.select((state) => state.settings.value.displaySettings.cardFontFamily);
|
cardFont$ = this.appService.select((state) => state.settings.value.displaySettings.cardFontFamily);
|
||||||
@ -27,15 +27,18 @@ export class AppComponent extends SubscriberBase implements AfterViewInit {
|
|||||||
shareReplay()
|
shareReplay()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
disableAnimation = false;
|
||||||
|
|
||||||
@ViewChild('drawer') public sidenav: MatSidenav;
|
@ViewChild('drawer') public sidenav: MatSidenav;
|
||||||
@ViewChild('settings') public settings: MatSidenav;
|
@ViewChild('settings') public settings: MatSidenav;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private appService: AppService,
|
private appService: AppService,
|
||||||
private navService: NavService,
|
|
||||||
private storageService: StorageService,
|
private storageService: StorageService,
|
||||||
private breakpointObserver: BreakpointObserver,
|
private breakpointObserver: BreakpointObserver,
|
||||||
private snackBar: MatSnackBar,
|
private snackBar: MatSnackBar,
|
||||||
|
private cd: ChangeDetectorRef,
|
||||||
|
public navService: NavService,
|
||||||
public dialog: MatDialog
|
public dialog: MatDialog
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
@ -92,6 +95,16 @@ export class AppComponent extends SubscriberBase implements AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
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 { 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({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class NavService {
|
export class NavService {
|
||||||
|
public swipe: Swipe;
|
||||||
|
|
||||||
private sidenav: MatSidenav;
|
private sidenav: MatSidenav;
|
||||||
private settings: MatSidenav;
|
private settings: MatSidenav;
|
||||||
|
|
||||||
public setSidenav(sidenav: MatSidenav, settings: MatSidenav) {
|
public afterViewInit(sidenav: MatSidenav, settings: MatSidenav, cd: ChangeDetectorRef) {
|
||||||
this.sidenav = sidenav;
|
this.sidenav = sidenav;
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
|
this.swipe = new Swipe(sidenav, settings, cd);
|
||||||
|
this.swipe.afterViewInit('drawer', 'settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDestroy() {
|
||||||
|
this.swipe.onDestroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
public openNav() {
|
public openNav() {
|
||||||
@ -34,6 +348,7 @@ export class NavService {
|
|||||||
const r = await this.settings.close();
|
const r = await this.settings.close();
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
public toggleSettings(): void {
|
public toggleSettings(): void {
|
||||||
this.settings.toggle();
|
this.settings.toggle();
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user