Full height swipe-able menus (#6566)

* Feature: Full height swipe enabled menus

support pan events on iphone
This commit is contained in:
Jeff Wong 2018-12-11 09:15:20 -08:00 committed by GitHub
parent 688755baf2
commit 71d8807fec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 383 additions and 36 deletions

View File

@ -1,6 +1,11 @@
import MountWidget from "discourse/components/mount-widget"; import MountWidget from "discourse/components/mount-widget";
import { observes } from "ember-addons/ember-computed-decorators"; import { observes } from "ember-addons/ember-computed-decorators";
import Docking from "discourse/mixins/docking"; import Docking from "discourse/mixins/docking";
import PanEvents, {
SWIPE_VELOCITY,
SWIPE_DISTANCE_THRESHOLD,
SWIPE_VELOCITY_THRESHOLD
} from "discourse/mixins/pan-events";
const _flagProperties = []; const _flagProperties = [];
function addFlagProperty(prop) { function addFlagProperty(prop) {
@ -9,10 +14,20 @@ function addFlagProperty(prop) {
const PANEL_BODY_MARGIN = 30; const PANEL_BODY_MARGIN = 30;
const SiteHeaderComponent = MountWidget.extend(Docking, { //android supports pulling in from the screen edges
const SCREEN_EDGE_MARGIN = 30;
const SCREEN_OFFSET = 300;
const SiteHeaderComponent = MountWidget.extend(Docking, PanEvents, {
widget: "header", widget: "header",
docAt: null, docAt: null,
dockedHeader: null, dockedHeader: null,
_animate: false,
_isPanning: false,
_panMenuOrigin: "right",
_panMenuOffset: 0,
_scheduledMovingAnimation: null,
_scheduledRemoveAnimate: null,
_topic: null, _topic: null,
@observes( @observes(
@ -23,6 +38,141 @@ const SiteHeaderComponent = MountWidget.extend(Docking, {
this.queueRerender(); this.queueRerender();
}, },
_animateOpening($panel) {
$panel.css({ right: "", left: "" });
this._panMenuOffset = 0;
},
_animateClosing($panel, menuOrigin, windowWidth) {
$panel.css(menuOrigin, -windowWidth);
this._animate = true;
Ember.run.schedule("afterRender", () => {
this.eventDispatched("dom:clean", "header");
this._panMenuOffset = 0;
});
},
_handlePanDone(offset, event) {
const $window = $(window);
const windowWidth = parseInt($window.width());
const $menuPanels = $(".menu-panel");
const menuOrigin = this._panMenuOrigin;
this._shouldMenuClose(event, menuOrigin)
? (offset += SWIPE_VELOCITY)
: (offset -= SWIPE_VELOCITY);
$menuPanels.each((idx, panel) => {
const $panel = $(panel);
const $headerCloak = $(".header-cloak");
$panel.css(menuOrigin, -offset);
$headerCloak.css("opacity", Math.min(0.5, (300 - offset) / 600));
if (offset > windowWidth) {
this._animateClosing($panel, menuOrigin, windowWidth);
} else if (offset <= 0) {
this._animateOpening($panel);
} else {
//continue to open or close menu
this._scheduledMovingAnimation = window.requestAnimationFrame(() =>
this._handlePanDone(offset, event)
);
}
});
},
_shouldMenuClose(e, menuOrigin) {
// menu should close after a pan either:
// if a user moved the panel closed past a threshold and away and is NOT swiping back open
// if a user swiped to close fast enough regardless of distance
if (menuOrigin === "right") {
return (
(e.deltaX > SWIPE_DISTANCE_THRESHOLD &&
e.velocityX > -SWIPE_VELOCITY_THRESHOLD) ||
e.velocityX > 0
);
} else {
return (
(e.deltaX < -SWIPE_DISTANCE_THRESHOLD &&
e.velocityX < SWIPE_VELOCITY_THRESHOLD) ||
e.velocityX < 0
);
}
},
panStart(e) {
const center = e.center;
const $centeredElement = $(document.elementFromPoint(center.x, center.y));
const $window = $(window);
const windowWidth = parseInt($window.width());
if (
($centeredElement.hasClass("panel-body") ||
$centeredElement.hasClass("header-cloak") ||
$centeredElement.parents(".panel-body").length) &&
(e.direction === "left" || e.direction === "right")
) {
e.originalEvent.preventDefault();
this._isPanning = true;
} else if (
center.x < SCREEN_EDGE_MARGIN &&
!this.$(".menu-panel").length &&
e.direction === "right"
) {
this._animate = false;
this._panMenuOrigin = "left";
this._panMenuOffset = -SCREEN_OFFSET;
this._isPanning = true;
$("header.d-header").removeClass("scroll-down scroll-up");
this.eventDispatched("toggleHamburger", "header");
} else if (
windowWidth - center.x < SCREEN_EDGE_MARGIN &&
!this.$(".menu-panel").length &&
e.direction === "left"
) {
this._animate = false;
this._panMenuOrigin = "right";
this._panMenuOffset = -SCREEN_OFFSET;
this._isPanning = true;
$("header.d-header").removeClass("scroll-down scroll-up");
this.eventDispatched("toggleUserMenu", "header");
} else {
this._isPanning = false;
}
},
panEnd(e) {
if (!this._isPanning) {
return;
}
this._isPanning = false;
$(".menu-panel").each((idx, panel) => {
const $panel = $(panel);
let offset = $panel.css("right");
if (this._panMenuOrigin === "left") {
offset = $panel.css("left");
}
offset = Math.abs(parseInt(offset, 10));
this._handlePanDone(offset, e);
});
},
panMove(e) {
if (!this._isPanning) {
return;
}
const $menuPanels = $(".menu-panel");
$menuPanels.each((idx, panel) => {
const $panel = $(panel);
const $headerCloak = $(".header-cloak");
if (this._panMenuOrigin === "right") {
const pxClosed = Math.min(0, -e.deltaX + this._panMenuOffset);
$panel.css("right", pxClosed);
$headerCloak.css("opacity", Math.min(0.5, (300 + pxClosed) / 600));
} else {
const pxClosed = Math.min(0, e.deltaX + this._panMenuOffset);
$panel.css("left", pxClosed);
$headerCloak.css("opacity", Math.min(0.5, (300 + pxClosed) / 600));
}
});
},
dockCheck(info) { dockCheck(info) {
const $header = $("header.d-header"); const $header = $("header.d-header");
@ -47,6 +197,7 @@ const SiteHeaderComponent = MountWidget.extend(Docking, {
}, },
setTopic(topic) { setTopic(topic) {
this.eventDispatched("dom:clean", "header");
this._topic = topic; this._topic = topic;
this.queueRerender(); this.queueRerender();
}, },
@ -74,6 +225,14 @@ const SiteHeaderComponent = MountWidget.extend(Docking, {
this.eventDispatched("dom:clean", "header"); this.eventDispatched("dom:clean", "header");
} }
}); });
if (this.site.mobileView) {
$("body")
.on("pointerdown", e => this._panStart(e))
.on("pointermove", e => this._panMove(e))
.on("pointerup", e => this._panMove(e))
.on("pointercancel", e => this._panMove(e));
}
}, },
willDestroyElement() { willDestroyElement() {
@ -84,6 +243,16 @@ const SiteHeaderComponent = MountWidget.extend(Docking, {
this.appEvents.off("header:show-topic"); this.appEvents.off("header:show-topic");
this.appEvents.off("header:hide-topic"); this.appEvents.off("header:hide-topic");
this.appEvents.off("dom:clean"); this.appEvents.off("dom:clean");
if (this.site.mobileView) {
$("body")
.off("pointerdown")
.off("pointerup")
.off("pointermove")
.off("pointercancel");
}
Ember.run.cancel(this._scheduledRemoveAnimate);
window.cancelAnimationFrame(this._scheduledMovingAnimation);
}, },
buildArgs() { buildArgs() {
@ -100,6 +269,9 @@ const SiteHeaderComponent = MountWidget.extend(Docking, {
afterRender() { afterRender() {
const $menuPanels = $(".menu-panel"); const $menuPanels = $(".menu-panel");
if ($menuPanels.length === 0) { if ($menuPanels.length === 0) {
if (this.site.mobileView) {
this._animate = true;
}
return; return;
} }
@ -112,15 +284,29 @@ const SiteHeaderComponent = MountWidget.extend(Docking, {
$menuPanels.each((idx, panel) => { $menuPanels.each((idx, panel) => {
const $panel = $(panel); const $panel = $(panel);
const $headerCloak = $(".header-cloak");
let width = parseInt($panel.attr("data-max-width") || 300); let width = parseInt($panel.attr("data-max-width") || 300);
if (windowWidth - width < 50) { if (windowWidth - width < 50) {
width = windowWidth - 50; width = windowWidth - 50;
} }
if (this._panMenuOffset) {
this._panMenuOffset = -width;
}
$panel $panel.removeClass("drop-down slide-in").addClass(viewMode);
.removeClass("drop-down") if (this._animate || this._panMenuOffset !== 0) {
.removeClass("slide-in") $headerCloak.css("opacity", 0);
.addClass(viewMode); if (
this.site.mobileView &&
$panel.parent(".hamburger-panel").length > 0
) {
this._panMenuOrigin = "left";
$panel.css("left", -windowWidth);
} else {
this._panMenuOrigin = "right";
$panel.css("right", -windowWidth);
}
}
const $panelBody = $(".panel-body", $panel); const $panelBody = $(".panel-body", $panel);
// 2 pixel fudge allows for firefox subpixel sizing stuff causing scrollbar // 2 pixel fudge allows for firefox subpixel sizing stuff causing scrollbar
@ -150,7 +336,8 @@ const SiteHeaderComponent = MountWidget.extend(Docking, {
if ( if (
contentHeight + (offsetTop - scrollTop) + PANEL_BODY_MARGIN > contentHeight + (offsetTop - scrollTop) + PANEL_BODY_MARGIN >
fullHeight fullHeight ||
this.site.mobileView
) { ) {
contentHeight = contentHeight =
fullHeight - (offsetTop - scrollTop) - PANEL_BODY_MARGIN; fullHeight - (offsetTop - scrollTop) - PANEL_BODY_MARGIN;
@ -160,11 +347,19 @@ const SiteHeaderComponent = MountWidget.extend(Docking, {
} }
$("body").addClass("drop-down-mode"); $("body").addClass("drop-down-mode");
} else { } else {
const menuTop = headerHeight(); if (this.site.mobileView) {
$headerCloak.show();
}
const menuTop = this.site.mobileView ? 0 : headerHeight();
let height; let height;
const winHeight = $(window).height() - 16; const winHeightOffset = 16;
if (menuTop + contentHeight < winHeight) { let initialWinHeight = window.innerHeight
? window.innerHeight
: $(window).height();
const winHeight = initialWinHeight - winHeightOffset;
if (menuTop + contentHeight < winHeight && !this.site.mobileView) {
height = contentHeight + "px"; height = contentHeight + "px";
} else { } else {
height = winHeight - menuTop; height = winHeight - menuTop;
@ -180,6 +375,17 @@ const SiteHeaderComponent = MountWidget.extend(Docking, {
} }
$panel.width(width); $panel.width(width);
if (this._animate) {
$panel.addClass("animate");
$headerCloak.addClass("animate");
this._scheduledRemoveAnimate = Ember.run.later(() => {
$panel.removeClass("animate");
$headerCloak.removeClass("animate");
}, 200);
}
$panel.css({ right: "", left: "" });
$headerCloak.css("opacity", 0.5);
this._animate = false;
}); });
} }
}); });

View File

@ -1,6 +1,10 @@
import { observes } from "ember-addons/ember-computed-decorators"; import { observes } from "ember-addons/ember-computed-decorators";
import showModal from "discourse/lib/show-modal"; import showModal from "discourse/lib/show-modal";
import PanEvents from "discourse/mixins/pan-events"; import PanEvents, {
SWIPE_VELOCITY,
SWIPE_DISTANCE_THRESHOLD,
SWIPE_VELOCITY_THRESHOLD
} from "discourse/mixins/pan-events";
export default Ember.Component.extend(PanEvents, { export default Ember.Component.extend(PanEvents, {
composerOpen: null, composerOpen: null,
@ -117,10 +121,11 @@ export default Ember.Component.extend(PanEvents, {
} }
}, },
_panOpenClose(offset, velocity, direction) { _handlePanDone(offset, event) {
const $timelineContainer = $(".timeline-container"); const $timelineContainer = $(".timeline-container");
const maxOffset = parseInt($timelineContainer.css("height")); const maxOffset = parseInt($timelineContainer.css("height"), 10);
direction === "close" ? (offset += velocity) : (offset -= velocity);
this._shouldPanClose(event) ? (offset += SWIPE_VELOCITY) : (offset -= SWIPE_VELOCITY);
$timelineContainer.css("bottom", -offset); $timelineContainer.css("bottom", -offset);
if (offset > maxOffset) { if (offset > maxOffset) {
@ -129,42 +134,41 @@ export default Ember.Component.extend(PanEvents, {
$timelineContainer.css("bottom", ""); $timelineContainer.css("bottom", "");
} else { } else {
Ember.run.later( Ember.run.later(
() => this._panOpenClose(offset, velocity, direction), () => this._handlePanDone(offset, event),
20 20
); );
} }
}, },
_shouldPanClose(e) { _shouldPanClose(e) {
return (e.deltaY > 200 && e.velocityY > -0.15) || e.velocityY > 0.15; return (e.deltaY > SWIPE_DISTANCE_THRESHOLD && e.velocityY > -SWIPE_VELOCITY_THRESHOLD) || e.velocityY > SWIPE_VELOCITY_THRESHOLD;
}, },
panStart(e) { panStart(e) {
e.originalEvent.preventDefault();
const center = e.center; const center = e.center;
const $centeredElement = $(document.elementFromPoint(center.x, center.y)); const $centeredElement = $(document.elementFromPoint(center.x, center.y));
if ($centeredElement.parents(".timeline-scrollarea-wrapper").length) { if ($centeredElement.parents(".timeline-scrollarea-wrapper").length) {
this.set("isPanning", false); this.isPanning = false;
} else if (e.direction === "up" || e.direction === "down") { } else if (e.direction === "up" || e.direction === "down") {
this.set("isPanning", true); this.isPanning = true;
} }
}, },
panEnd(e) { panEnd(e) {
if (!this.get("isPanning")) { if (!this.isPanning) {
return; return;
} }
this.set("isPanning", false); e.originalEvent.preventDefault();
if (this._shouldPanClose(e)) { this.isPanning = false;
this._panOpenClose(e.deltaY, 40, "close"); this._handlePanDone(e.deltaY, e);
} else {
this._panOpenClose(e.deltaY, 40, "open");
}
}, },
panMove(e) { panMove(e) {
if (!this.get("isPanning")) { if (!this.isPanning) {
return; return;
} }
e.originalEvent.preventDefault();
$(".timeline-container").css("bottom", Math.min(0, -e.deltaY)); $(".timeline-container").css("bottom", Math.min(0, -e.deltaY));
}, },

View File

@ -1,3 +1,10 @@
/**
Pan events is a mixin that allows components to detect and respond to swipe gestures
It fires callbacks for panStart, panEnd, panMove with the pan state, and the original event.
**/
export const SWIPE_VELOCITY = 40;
export const SWIPE_DISTANCE_THRESHOLD = 50;
export const SWIPE_VELOCITY_THRESHOLD = 0.1;
export default Ember.Mixin.create({ export default Ember.Mixin.create({
//velocity is pixels per ms //velocity is pixels per ms
@ -7,11 +14,23 @@ export default Ember.Mixin.create({
this._super(); this._super();
if (this.site.mobileView) { if (this.site.mobileView) {
this.$() if ("onpointerdown" in document.documentElement) {
.on("pointerdown", e => this._panStart(e)) this.$()
.on("pointermove", e => this._panMove(e)) .on("pointerdown", e => this._panStart(e))
.on("pointerup", e => this._panMove(e)) .on("pointermove", e => this._panMove(e, e))
.on("pointercancel", e => this._panMove(e)); .on("pointerup", e => this._panMove(e, e))
.on("pointercancel", e => this._panMove(e, e));
} else if ("ontouchstart" in document.documentElement) {
this.$()
.on("touchstart", e => this._panStart(e.touches[0]))
.on("touchmove", e => {
const touchEvent = e.touches[0];
touchEvent.type = "pointermove";
this._panMove(touchEvent, e);
})
.on("touchend", e => this._panMove({ type: "pointerup" }, e))
.on("touchcancel", e => this._panMove({ type: "pointercancel" }, e));
}
} }
}, },
@ -23,7 +42,11 @@ export default Ember.Mixin.create({
.off("pointerdown") .off("pointerdown")
.off("pointerup") .off("pointerup")
.off("pointermove") .off("pointermove")
.off("pointercancel"); .off("pointercancel")
.off("touchstart")
.off("touchmove")
.off("touchend")
.off("touchcancel");
} }
}, },
@ -44,6 +67,9 @@ export default Ember.Mixin.create({
} }
const newTimestamp = new Date().getTime(); const newTimestamp = new Date().getTime();
const timeDiffSeconds = newTimestamp - oldState.timestamp; const timeDiffSeconds = newTimestamp - oldState.timestamp;
if (timeDiffSeconds === 0) {
return oldState;
}
//calculate delta x, y, distance from START location //calculate delta x, y, distance from START location
const deltaX = e.clientX - oldState.startLocation.x; const deltaX = e.clientX - oldState.startLocation.x;
const deltaY = e.clientY - oldState.startLocation.y; const deltaY = e.clientY - oldState.startLocation.y;
@ -93,7 +119,7 @@ export default Ember.Mixin.create({
this.set("_panState", newState); this.set("_panState", newState);
}, },
_panMove(e) { _panMove(e, originalEvent) {
if (!this.get("_panState")) { if (!this.get("_panState")) {
this._panStart(e); this._panStart(e);
return; return;
@ -104,6 +130,7 @@ export default Ember.Mixin.create({
return; return;
} }
this.set("_panState", newState); this.set("_panState", newState);
newState.originalEvent = originalEvent;
if (previousState.start && "panStart" in this) { if (previousState.start && "panStart" in this) {
this.panStart(newState); this.panStart(newState);
} else if ( } else if (

View File

@ -323,7 +323,32 @@ export default createWidget("hamburger-menu", {
}); });
}, },
clickOutside() { clickOutsideMobile(e) {
this.sendWidgetAction("toggleHamburger"); const $centeredElement = $(document.elementFromPoint(e.clientX, e.clientY));
if (
$centeredElement.parents(".panel").length &&
!$centeredElement.hasClass("header-cloak")
) {
this.sendWidgetAction("toggleHamburger");
} else {
const $window = $(window);
const windowWidth = parseInt($window.width(), 10);
const $panel = $(".menu-panel");
$panel.addClass("animate");
const panelOffsetDirection = this.site.mobileView ? "left" : "right";
$panel.css(panelOffsetDirection, -windowWidth);
const $headerCloak = $(".header-cloak");
$headerCloak.addClass("animate");
$headerCloak.css("opacity", 0);
Ember.run.later(() => this.sendWidgetAction("toggleHamburger"), 200);
}
},
clickOutside(e) {
if (this.site.mobileView) {
this.clickOutsideMobile(e);
} else {
this.sendWidgetAction("toggleHamburger");
}
} }
}); });

View File

@ -253,6 +253,15 @@ createWidget("header-buttons", {
} }
}); });
createWidget("header-cloak", {
tagName: "div.header-cloak",
html() {
return "";
},
click() {},
scheduleRerender() {}
});
const forceContextEnabled = ["category", "user", "private_messages"]; const forceContextEnabled = ["category", "user", "private_messages"];
let additionalPanels = []; let additionalPanels = [];
@ -315,6 +324,9 @@ export default createWidget("header", {
} else if (state.userVisible) { } else if (state.userVisible) {
panels.push(this.attach("user-menu")); panels.push(this.attach("user-menu"));
} }
if (this.site.mobileView) {
panels.push(this.attach("header-cloak"));
}
additionalPanels.map(panel => { additionalPanels.map(panel => {
if (this.state[panel.toggle]) { if (this.state[panel.toggle]) {
@ -348,6 +360,7 @@ export default createWidget("header", {
this.state.userVisible = false; this.state.userVisible = false;
this.state.hamburgerVisible = false; this.state.hamburgerVisible = false;
this.state.searchVisible = false; this.state.searchVisible = false;
this.toggleBodyScrolling(false);
}, },
linkClickedEvent(attrs) { linkClickedEvent(attrs) {
@ -416,10 +429,32 @@ export default createWidget("header", {
} }
this.state.userVisible = !this.state.userVisible; this.state.userVisible = !this.state.userVisible;
this.toggleBodyScrolling(this.state.userVisible);
}, },
toggleHamburger() { toggleHamburger() {
this.state.hamburgerVisible = !this.state.hamburgerVisible; this.state.hamburgerVisible = !this.state.hamburgerVisible;
this.toggleBodyScrolling(this.state.hamburgerVisible);
},
toggleBodyScrolling(bool) {
if (!this.site.mobileView) return;
if (bool) {
document.body.addEventListener("touchmove", this.preventDefault, { passive: false });
} else {
document.body.removeEventListener("touchmove", this.preventDefault, { passive: false });
}
},
preventDefault(e) {
// prevent all scrollin on menu panels, except on overflow
const height = window.innerHeight
? window.innerHeight
: $(window).height();
if (!$(e.target).parents(".menu-panel").length ||
$(".menu-panel .panel-body-contents").height() <= height) {
e.preventDefault();
}
}, },
togglePageSearch() { togglePageSearch() {

View File

@ -194,7 +194,31 @@ export default createWidget("user-menu", {
}); });
}, },
clickOutside() { clickOutsideMobile(e) {
this.sendWidgetAction("toggleUserMenu"); const $centeredElement = $(document.elementFromPoint(e.clientX, e.clientY));
if (
$centeredElement.parents(".panel").length &&
!$centeredElement.hasClass("header-cloak")
) {
this.sendWidgetAction("toggleUserMenu");
} else {
const $window = $(window);
const windowWidth = parseInt($window.width(), 10);
const $panel = $(".menu-panel");
$panel.addClass("animate");
$panel.css("right", -windowWidth);
const $headerCloak = $(".header-cloak");
$headerCloak.addClass("animate");
$headerCloak.css("opacity", 0);
Ember.run.later(() => this.sendWidgetAction("toggleUserMenu"), 200);
}
},
clickOutside(e) {
if (this.site.mobileView) {
this.clickOutsideMobile(e);
} else {
this.sendWidgetAction("toggleUserMenu");
}
} }
}); });

View File

@ -2,6 +2,9 @@
position: fixed; position: fixed;
right: 0; right: 0;
box-shadow: shadow("header"); box-shadow: shadow("header");
&.animate {
transition: right 0.2s ease-out, left 0.2s ease-out;
}
.panel-body { .panel-body {
position: absolute; position: absolute;
@ -10,6 +13,9 @@
width: 97%; width: 97%;
} }
} }
.header-cloak {
display: none;
}
.menu-panel.drop-down { .menu-panel.drop-down {
position: absolute; position: absolute;
@ -42,6 +48,7 @@
} }
.panel-body { .panel-body {
touch-action: pan-y pinch-zoom;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
} }

View File

@ -29,6 +29,7 @@
@import "mobile/admin_report"; @import "mobile/admin_report";
@import "mobile/admin_report_table"; @import "mobile/admin_report_table";
@import "mobile/admin_report_counters"; @import "mobile/admin_report_counters";
@import "mobile/menu-panel";
// Import all component-specific files // Import all component-specific files
@import "mobile/components/*"; @import "mobile/components/*";

View File

@ -0,0 +1,18 @@
.hamburger-panel .menu-panel.slide-in {
left: 0;
}
.header-cloak {
height: 100vh;
width: 100vw;
position: fixed;
background-color: black;
opacity: 0.5;
top: 0;
left: 0;
display: none;
touch-action: pan-y pinch-zoom;
&.animate {
transition: opacity 0.2s ease-out;
}
}