fix(frontend): 横スワイプの挙動改善

This commit is contained in:
kakkokari-gtyih 2026-01-15 00:06:22 +09:00
parent 153ebd4392
commit 630116e37c
1 changed files with 135 additions and 28 deletions

View File

@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@touchstart.passive="touchStart" @touchstart.passive="touchStart"
@touchmove.passive="touchMove" @touchmove.passive="touchMove"
@touchend.passive="touchEnd" @touchend.passive="touchEnd"
@touchcancel.passive="touchCancel"
> >
<Transition <Transition
:class="[$style.transitionChildren, { [$style.swiping]: isSwipingForClass }]" :class="[$style.transitionChildren, { [$style.swiping]: isSwipingForClass }]"
@ -26,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, useTemplateRef, computed, nextTick, watch } from 'vue'; import { ref, useTemplateRef, computed, watch } from 'vue';
import type { Tab } from '@/components/global/MkPageHeader.tabs.vue'; import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
import { isHorizontalSwipeSwiping as isSwiping } from '@/utility/touch.js'; import { isHorizontalSwipeSwiping as isSwiping } from '@/utility/touch.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
@ -59,11 +60,22 @@ const MAX_SWIPE_DISTANCE = 120;
// //
const SWIPE_DIRECTION_ANGLE_THRESHOLD = 50; const SWIPE_DIRECTION_ANGLE_THRESHOLD = 50;
//
const RELEASE_TRANSITION_DURATION = 200;
//
const DIRECTION_LOCK_START_DISTANCE = 6;
// // // //
let startScreenX: number | null = null; let startScreenX: number | null = null;
let startScreenY: number | null = null; let startScreenY: number | null = null;
let isTracking = false;
let rafId: number | null = null;
let pendingPullDistance: number | null = null;
let releaseAnimationCancel: (() => void) | null = null;
const currentTabIndex = computed(() => props.tabs.findIndex(tab => tab.key === tabModel.value)); const currentTabIndex = computed(() => props.tabs.findIndex(tab => tab.key === tabModel.value));
const pullDistance = ref(0); const pullDistance = ref(0);
@ -71,6 +83,88 @@ const isSwipingForClass = ref(false);
let swipeAborted = false; let swipeAborted = false;
let swipeDirectionLocked: 'horizontal' | 'vertical' | null = null; let swipeDirectionLocked: 'horizontal' | 'vertical' | null = null;
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function toEffectiveDistance(rawDistance: number): number {
const sign = Math.sign(rawDistance);
const abs = Math.abs(rawDistance);
if (abs <= MIN_SWIPE_DISTANCE) return 0;
return sign * (abs - MIN_SWIPE_DISTANCE);
}
function setPullDistanceDirect(nextDistance: number) {
pendingPullDistance = nextDistance;
if (rafId != null) return;
rafId = window.requestAnimationFrame(() => {
rafId = null;
const next = pendingPullDistance ?? 0;
pendingPullDistance = null;
// : 0.5px
if (Math.abs(next - pullDistance.value) < 0.5) return;
pullDistance.value = next;
});
}
function cancelReleaseAnimation() {
if (releaseAnimationCancel) {
releaseAnimationCancel();
releaseAnimationCancel = null;
}
}
function animatePullDistanceTo(to: number, duration = RELEASE_TRANSITION_DURATION): Promise<void> {
cancelReleaseAnimation();
if (!shouldAnimate.value || duration <= 0) {
setPullDistanceDirect(to);
return Promise.resolve();
}
return new Promise(resolve => {
const from = pullDistance.value;
const delta = to - from;
if (Math.abs(delta) < 0.5) {
setPullDistanceDirect(to);
resolve();
return;
}
const startTime = performance.now();
let cancelled = false;
releaseAnimationCancel = () => {
cancelled = true;
};
const tick = (now: number) => {
if (cancelled) {
resolve();
return;
}
const t = Math.min((now - startTime) / duration, 1);
//
const eased = 1 - Math.pow(1 - t, 3);
setPullDistanceDirect(from + delta * eased);
if (t >= 1) {
releaseAnimationCancel = null;
resolve();
return;
}
window.requestAnimationFrame(tick);
};
window.requestAnimationFrame(tick);
});
}
function resetSwipeState() {
startScreenX = null;
startScreenY = null;
isTracking = false;
swipeDirectionLocked = null;
isSwiping.value = false;
}
function touchStart(event: TouchEvent) { function touchStart(event: TouchEvent) {
if (!prefer.r.enableHorizontalSwipe.value) return; if (!prefer.r.enableHorizontalSwipe.value) return;
@ -78,9 +172,13 @@ function touchStart(event: TouchEvent) {
if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return; if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return;
cancelReleaseAnimation();
startScreenX = event.touches[0].screenX; startScreenX = event.touches[0].screenX;
startScreenY = event.touches[0].screenY; startScreenY = event.touches[0].screenY;
isTracking = true;
swipeDirectionLocked = null; // swipeDirectionLocked = null; //
swipeAborted = false;
} }
function touchMove(event: TouchEvent) { function touchMove(event: TouchEvent) {
@ -89,37 +187,41 @@ function touchMove(event: TouchEvent) {
if (event.touches.length !== 1) return; if (event.touches.length !== 1) return;
if (startScreenX == null || startScreenY == null) return; if (startScreenX == null || startScreenY == null) return;
if (!isTracking) return;
if (swipeAborted) return; if (swipeAborted) return;
if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return; if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return;
let distanceX = event.touches[0].screenX - startScreenX; const rawDistanceX = event.touches[0].screenX - startScreenX;
let distanceY = event.touches[0].screenY - startScreenY; const rawDistanceY = event.touches[0].screenY - startScreenY;
// //
if (!swipeDirectionLocked) { if (!swipeDirectionLocked) {
const angle = Math.abs(Math.atan2(distanceY, distanceX) * (180 / Math.PI)); const moveDistance = Math.hypot(rawDistanceX, rawDistanceY);
if (angle > 90 - SWIPE_DIRECTION_ANGLE_THRESHOLD && angle < 90 + SWIPE_DIRECTION_ANGLE_THRESHOLD) { if (moveDistance >= DIRECTION_LOCK_START_DISTANCE) {
swipeDirectionLocked = 'vertical'; const angle = Math.abs(Math.atan2(rawDistanceY, rawDistanceX) * (180 / Math.PI));
} else { if (angle > 90 - SWIPE_DIRECTION_ANGLE_THRESHOLD && angle < 90 + SWIPE_DIRECTION_ANGLE_THRESHOLD) {
swipeDirectionLocked = 'horizontal'; swipeDirectionLocked = 'vertical';
} else {
swipeDirectionLocked = 'horizontal';
}
} }
} }
// //
if (swipeDirectionLocked === 'vertical') { if (swipeDirectionLocked === 'vertical') {
swipeAborted = true; swipeAborted = true;
pullDistance.value = 0; setPullDistanceDirect(0);
isSwiping.value = false; resetSwipeState();
window.setTimeout(() => { //
isSwipingForClass.value = false; isSwipingForClass.value = false;
}, 400);
return; return;
} }
if (Math.abs(distanceX) < MIN_SWIPE_DISTANCE) return; if (Math.abs(rawDistanceX) < MIN_SWIPE_DISTANCE) return;
if (Math.abs(distanceX) > MAX_SWIPE_DISTANCE) return;
let distanceX = clamp(toEffectiveDistance(rawDistanceX), -MAX_SWIPE_DISTANCE, MAX_SWIPE_DISTANCE);
if (currentTabIndex.value === 0 || props.tabs[currentTabIndex.value - 1].onClick) { if (currentTabIndex.value === 0 || props.tabs[currentTabIndex.value - 1].onClick) {
distanceX = Math.min(distanceX, 0); distanceX = Math.min(distanceX, 0);
@ -131,16 +233,13 @@ function touchMove(event: TouchEvent) {
isSwiping.value = true; isSwiping.value = true;
isSwipingForClass.value = true; isSwipingForClass.value = true;
nextTick(() => { setPullDistanceDirect(distanceX);
// 1.5px
if (Math.abs(distanceX - pullDistance.value) < 1.5) return;
pullDistance.value = distanceX;
});
} }
function touchEnd(event: TouchEvent) { function touchEnd(event: TouchEvent) {
if (swipeAborted) { if (swipeAborted) {
swipeAborted = false; swipeAborted = false;
resetSwipeState();
return; return;
} }
@ -149,14 +248,17 @@ function touchEnd(event: TouchEvent) {
if (event.touches.length !== 0) return; if (event.touches.length !== 0) return;
if (startScreenX == null) return; if (startScreenX == null) return;
if (!isTracking) return;
if (!isSwiping.value) return; if (!isSwiping.value) return;
if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return; if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return;
const distance = event.changedTouches[0].screenX - startScreenX; const distance = event.changedTouches[0].screenX - startScreenX;
const effectiveDistance = toEffectiveDistance(distance);
const effectiveThreshold = Math.max(SWIPE_DISTANCE_THRESHOLD - MIN_SWIPE_DISTANCE, 0);
if (Math.abs(distance) > SWIPE_DISTANCE_THRESHOLD) { if (Math.abs(effectiveDistance) > effectiveThreshold) {
if (distance > 0) { if (distance > 0) {
if (props.tabs[currentTabIndex.value - 1] && !props.tabs[currentTabIndex.value - 1].onClick) { if (props.tabs[currentTabIndex.value - 1] && !props.tabs[currentTabIndex.value - 1].onClick) {
tabModel.value = props.tabs[currentTabIndex.value - 1].key; tabModel.value = props.tabs[currentTabIndex.value - 1].key;
@ -170,13 +272,18 @@ function touchEnd(event: TouchEvent) {
} }
} }
pullDistance.value = 0; resetSwipeState();
isSwiping.value = false; animatePullDistanceTo(0).finally(() => {
window.setTimeout(() => {
isSwipingForClass.value = false; isSwipingForClass.value = false;
}, 400); });
}
swipeDirectionLocked = null; // function touchCancel(_event: TouchEvent) {
swipeAborted = false;
resetSwipeState();
animatePullDistanceTo(0).finally(() => {
isSwipingForClass.value = false;
});
} }
/** 横スワイプに関与する可能性のある要素を調べる */ /** 横スワイプに関与する可能性のある要素を調べる */
@ -187,7 +294,7 @@ function hasSomethingToDoWithXSwipe(el: HTMLElement) {
const style = window.getComputedStyle(el); const style = window.getComputedStyle(el);
if (['absolute', 'fixed', 'sticky'].includes(style.position)) return true; if (['absolute', 'fixed', 'sticky'].includes(style.position)) return true;
if (['scroll', 'auto'].includes(style.overflowX)) return true; if (style.overflowX === 'scroll') return true;
if (style.touchAction === 'pan-x') return true; if (style.touchAction === 'pan-x') return true;
if (el.parentElement && el.parentElement !== rootEl.value) { if (el.parentElement && el.parentElement !== rootEl.value) {
@ -246,6 +353,6 @@ watch(tabModel, (newTab, oldTab) => {
} }
.swiping { .swiping {
transition: transform .2s ease-out; transition: none;
} }
</style> </style>