fix(frontend): 横スワイプの挙動改善
This commit is contained in:
parent
153ebd4392
commit
630116e37c
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue