refactor PageTransition and PullDownRefresh
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<t-pull-down-refresh
|
||||
ref="rootRef"
|
||||
class="app-pull-down-refresh"
|
||||
v-bind="forwardedAttrs"
|
||||
@touchstart.capture="guardTouch"
|
||||
@touchmove.capture="guardTouch"
|
||||
@touchend.capture="guardTouch"
|
||||
@touchcancel.capture="guardTouch"
|
||||
>
|
||||
<slot />
|
||||
</t-pull-down-refresh>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ComponentPublicInstance } from "vue";
|
||||
|
||||
defineOptions({
|
||||
name: "AppPullDownRefresh",
|
||||
inheritAttrs: false
|
||||
});
|
||||
|
||||
type EventListener = (...args: unknown[]) => unknown;
|
||||
type ScrollTarget = HTMLElement | Window;
|
||||
|
||||
const scrollToLowerThreshold = 20;
|
||||
const attrs = useAttrs();
|
||||
const rootRef = ref<ComponentPublicInstance | null>(null);
|
||||
const forwardedAttrs = computed(() => Object.fromEntries(
|
||||
Object.entries(attrs).filter(([key]) => key !== "onScrolltolower")
|
||||
));
|
||||
let rootElement: HTMLElement | null = null;
|
||||
let scrollFrame = 0;
|
||||
let isScrollListenerBound = false;
|
||||
|
||||
function isScrollable(element: HTMLElement): boolean {
|
||||
const overflowY = window.getComputedStyle(element).overflowY;
|
||||
return /auto|scroll/i.test(overflowY) && element.clientHeight < element.scrollHeight;
|
||||
}
|
||||
|
||||
function getScrollParents(target: EventTarget | null): HTMLElement[] {
|
||||
let current = target instanceof HTMLElement
|
||||
? target
|
||||
: target instanceof Element ? target.parentElement : null;
|
||||
const parents: HTMLElement[] = [];
|
||||
while (current) {
|
||||
if (isScrollable(current)) {
|
||||
parents.push(current);
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return parents;
|
||||
}
|
||||
|
||||
function getWindowScrollTop(): number {
|
||||
return window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop;
|
||||
}
|
||||
|
||||
function isAtTop(target: EventTarget | null): boolean {
|
||||
if (getScrollParents(target).some(parent => 0 < parent.scrollTop)) {
|
||||
return false;
|
||||
}
|
||||
return getWindowScrollTop() < 1;
|
||||
}
|
||||
|
||||
function guardTouch(event: TouchEvent): void {
|
||||
if (isAtTop(event.target)) {
|
||||
return;
|
||||
}
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
function getScrollTarget(event: Event): ScrollTarget {
|
||||
return getScrollParents(event.target)[0] || window;
|
||||
}
|
||||
|
||||
function isReachBottom(target: ScrollTarget): boolean {
|
||||
if (target === window) {
|
||||
const documentElement = document.documentElement;
|
||||
const scrollHeight = Math.max(documentElement.scrollHeight, document.body.scrollHeight);
|
||||
const clientHeight = window.innerHeight || documentElement.clientHeight;
|
||||
return clientHeight < scrollHeight && scrollHeight - scrollToLowerThreshold < getWindowScrollTop() + clientHeight;
|
||||
}
|
||||
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
return target.clientHeight < target.scrollHeight
|
||||
&& target.scrollHeight - scrollToLowerThreshold < target.scrollTop + target.clientHeight;
|
||||
}
|
||||
|
||||
function invokeListener(listener: unknown): void {
|
||||
if (Array.isArray(listener)) {
|
||||
for (const item of listener) {
|
||||
invokeListener(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof listener === "function") {
|
||||
(listener as EventListener)();
|
||||
}
|
||||
}
|
||||
|
||||
function handleScroll(event: Event): void {
|
||||
const target = getScrollTarget(event);
|
||||
if (scrollFrame || !isReachBottom(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollFrame = requestAnimationFrame(() => {
|
||||
scrollFrame = 0;
|
||||
if (isReachBottom(target)) {
|
||||
invokeListener(attrs.onScrolltolower);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindScrollListeners(): void {
|
||||
if (isScrollListenerBound) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = rootRef.value?.$el;
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
rootElement = element;
|
||||
rootElement.addEventListener("scroll", handleScroll, true);
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
isScrollListenerBound = true;
|
||||
}
|
||||
|
||||
function unbindScrollListeners(): void {
|
||||
if (!isScrollListenerBound) {
|
||||
return;
|
||||
}
|
||||
|
||||
rootElement?.removeEventListener("scroll", handleScroll, true);
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
isScrollListenerBound = false;
|
||||
if (scrollFrame) {
|
||||
cancelAnimationFrame(scrollFrame);
|
||||
scrollFrame = 0;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(bindScrollListeners);
|
||||
onActivated(bindScrollListeners);
|
||||
onDeactivated(unbindScrollListeners);
|
||||
onUnmounted(unbindScrollListeners);
|
||||
</script>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<route-page :background="background" :component="component" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from "vue";
|
||||
import RoutePage from "@/components/RoutePage.vue";
|
||||
|
||||
defineOptions({
|
||||
name: "CachedRoutePage"
|
||||
});
|
||||
|
||||
defineProps<{
|
||||
background: string;
|
||||
component: Component;
|
||||
}>();
|
||||
</script>
|
||||
@@ -38,7 +38,7 @@
|
||||
@status-change="handleScanStatusChange"
|
||||
/>
|
||||
</div>
|
||||
<t-pull-down-refresh
|
||||
<app-pull-down-refresh
|
||||
class="result-list"
|
||||
v-model="isRefreshing"
|
||||
:refresh-timeout="30000"
|
||||
@@ -52,7 +52,7 @@
|
||||
:empty-text="loading ? '正在加载客户' : '未找到匹配客户'"
|
||||
@select="toggleCustomer"
|
||||
/>
|
||||
</t-pull-down-refresh>
|
||||
</app-pull-down-refresh>
|
||||
</div>
|
||||
</bottom-popup>
|
||||
</template>
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
<div class="page-transition">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition :name="transitionName">
|
||||
<div :key="route.path" class="pages">
|
||||
<component :is="Component" />
|
||||
</div>
|
||||
<keep-alive :include="keepAlivePageNames">
|
||||
<component
|
||||
:is="isCachedPage ? CachedRoutePage : RoutePage"
|
||||
:key="route.path"
|
||||
:background="pageBackground"
|
||||
:component="Component"
|
||||
/>
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
@@ -12,15 +17,21 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationNormalized } from "vue-router";
|
||||
import CachedRoutePage from "@/components/CachedRoutePage.vue";
|
||||
import RoutePage from "@/components/RoutePage.vue";
|
||||
import { useGlobalUIStore } from "@/store/globalUI";
|
||||
|
||||
defineOptions({
|
||||
name: "PageTransition"
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const globalUIStore = useGlobalUIStore();
|
||||
// KeepAlive 直接缓存的是单根路由边界,列表页面位于该边界子树中,仍会收到 activated / deactivated 生命周期。
|
||||
const keepAlivePageNames = ["CachedRoutePage"];
|
||||
const cachedPageRouteNameSet = new Set(["SalesOrderList", "ProductList", "ProductStockList"]);
|
||||
const isCachedPage = computed(() => cachedPageRouteNameSet.has(String(route.name)));
|
||||
|
||||
const transitionName = ref("push-left");
|
||||
const pageBackground = computed(() => globalUIStore.bodyBackground);
|
||||
@@ -46,7 +57,7 @@ function calcDepth(sourceRoute: RouteLocationNormalized): number {
|
||||
}
|
||||
|
||||
const baseDepth = Number(sourceRoute.meta.baseDepth ?? 0);
|
||||
const pathSegments = fullPath.split("?")[0].split("/").filter((pathSegment) => pathSegment !== "");
|
||||
const pathSegments = fullPath.split("?")[0].split("/").filter(pathSegment => pathSegment !== "");
|
||||
const depth = baseDepth + (pathSegments.length - 1);
|
||||
pathCache.set(fullPath, depth);
|
||||
return depth;
|
||||
@@ -103,23 +114,15 @@ onUnmounted(() => {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
|
||||
// 此 wrapper 必须独立,不可将 class=pages 直接加在 <component> 上:
|
||||
// 否则页面 root element 与动画容器同为一个 DOM 节点,
|
||||
// 页面自身的 padding 会使 position:fixed; width:100% 的盒子溢出视口。
|
||||
.pages {
|
||||
height: 100%;
|
||||
background: v-bind(pageBackground);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
overflow-x: clip;
|
||||
z-index: 0;
|
||||
|
||||
// ---------- push-left / push-right 公共激活状态 ----------
|
||||
|
||||
.push-left-enter-active,
|
||||
.push-left-leave-active,
|
||||
.push-right-enter-active,
|
||||
.push-right-leave-active {
|
||||
:deep(.push-left-enter-active),
|
||||
:deep(.push-left-leave-active),
|
||||
:deep(.push-right-enter-active),
|
||||
:deep(.push-right-leave-active) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: fixed;
|
||||
@@ -127,51 +130,51 @@ onUnmounted(() => {
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
// ---------- 前进:下一页从右滑入,当前页缩退到左侧 ----------
|
||||
// ---------- push-left:下一页从右滑入,当前页缩退到左侧 ----------
|
||||
|
||||
.push-left-enter-from {
|
||||
:deep(.push-left-enter-from) {
|
||||
transform: translate3d(100%, 0, 0);
|
||||
box-shadow: -4px 0 16px rgba(0, 0, 0, @shadow-intensity);
|
||||
}
|
||||
|
||||
.push-left-enter-to {
|
||||
:deep(.push-left-enter-to) {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
.push-left-leave-active {
|
||||
:deep(.push-left-leave-active) {
|
||||
transition: all @duration @easing, filter .3s;
|
||||
}
|
||||
|
||||
.push-left-leave-to {
|
||||
:deep(.push-left-leave-to) {
|
||||
filter: brightness(@brightness);
|
||||
transform: translate3d(-@page-offset, 0, 0);
|
||||
}
|
||||
|
||||
// ---------- 返回:当前页从右滑出,上一页从左侧复位 ----------
|
||||
// ---------- push-right:当前页从右滑出,上一页从左侧复位 ----------
|
||||
|
||||
.push-right-enter-active {
|
||||
:deep(.push-right-enter-active) {
|
||||
z-index: 0;
|
||||
transition: transform @duration @easing, opacity .3s;
|
||||
}
|
||||
|
||||
.push-right-enter-from {
|
||||
:deep(.push-right-enter-from) {
|
||||
transform: translate3d(-@page-offset, 0, 0);
|
||||
}
|
||||
|
||||
.push-right-enter-to {
|
||||
:deep(.push-right-enter-to) {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
.push-right-leave-active {
|
||||
:deep(.push-right-leave-active) {
|
||||
z-index: 1;
|
||||
transition: transform @duration @easing, box-shadow .3s;
|
||||
}
|
||||
|
||||
.push-right-leave-from {
|
||||
:deep(.push-right-leave-from) {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
.push-right-leave-to {
|
||||
:deep(.push-right-leave-to) {
|
||||
transform: translate3d(105%, 0, 0);
|
||||
box-shadow: 4px 0 16px rgba(0, 0, 0, @shadow-intensity);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<component :is="component" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from "vue";
|
||||
|
||||
defineOptions({
|
||||
name: "RoutePage"
|
||||
});
|
||||
|
||||
defineProps<{
|
||||
component: Component;
|
||||
background: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
overflow-x: clip;
|
||||
background: v-bind(background);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user