summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components
diff options
context:
space:
mode:
authorかっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>2024-01-18 18:21:33 +0900
committerGitHub <noreply@github.com>2024-01-18 18:21:33 +0900
commitc1019a006bfa48ba7398daa02788c50be0bfb35a (patch)
tree0f3e50544afbb216ca25732b506370a421d94299 /packages/frontend/src/components
parentfix(frontend/MediaVideo): 再生シークバーの当たり判定を調整 (#... (diff)
downloadmisskey-c1019a006bfa48ba7398daa02788c50be0bfb35a.tar.gz
misskey-c1019a006bfa48ba7398daa02788c50be0bfb35a.tar.bz2
misskey-c1019a006bfa48ba7398daa02788c50be0bfb35a.zip
feat(frontend): 横スワイプでタブを切り替える機能 (#13011)
* (add) 横スワイプでタブを切り替える機能 * Change Changelog * y方向の移動が一定量を超えたらスワイプを中断するように * Update swipe distance thresholds * Remove console.log * adjust threshold * rename, use v-model * fix * Update MkHorizontalSwipe.vue Co-authored-by: syuilo <Syuilotan@yahoo.co.jp> * use css module --------- Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
Diffstat (limited to 'packages/frontend/src/components')
-rw-r--r--packages/frontend/src/components/MkHorizontalSwipe.vue209
1 files changed, 209 insertions, 0 deletions
diff --git a/packages/frontend/src/components/MkHorizontalSwipe.vue b/packages/frontend/src/components/MkHorizontalSwipe.vue
new file mode 100644
index 0000000000..2c62aadbf4
--- /dev/null
+++ b/packages/frontend/src/components/MkHorizontalSwipe.vue
@@ -0,0 +1,209 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div
+ ref="rootEl"
+ :class="[$style.transitionRoot, (defaultStore.state.animation && $style.enableAnimation)]"
+ @touchstart="touchStart"
+ @touchmove="touchMove"
+ @touchend="touchEnd"
+>
+ <Transition
+ :class="[$style.transitionChildren, { [$style.swiping]: isSwipingForClass }]"
+ :enterActiveClass="$style.swipeAnimation_enterActive"
+ :leaveActiveClass="$style.swipeAnimation_leaveActive"
+ :enterFromClass="transitionName === 'swipeAnimationLeft' ? $style.swipeAnimationLeft_enterFrom : $style.swipeAnimationRight_enterFrom"
+ :leaveToClass="transitionName === 'swipeAnimationLeft' ? $style.swipeAnimationLeft_leaveTo : $style.swipeAnimationRight_leaveTo"
+ :style="`--swipe: ${pullDistance}px;`"
+ >
+ <!-- 【注意】slot内の最上位要素に動的にkeyを設定すること -->
+ <!-- 各最上位要素にユニークなkeyの指定がないとTransitionがうまく動きません -->
+ <slot></slot>
+ </Transition>
+</div>
+</template>
+<script lang="ts" setup>
+import { ref, shallowRef, computed, nextTick, watch } from 'vue';
+import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
+import { defaultStore } from '@/store.js';
+
+const rootEl = shallowRef<HTMLDivElement>();
+
+// eslint-disable-next-line no-undef
+const tabModel = defineModel<string>('tab');
+
+const props = defineProps<{
+ tabs: Tab[];
+}>();
+
+const emit = defineEmits<{
+ (ev: 'swiped', newKey: string, direction: 'left' | 'right'): void;
+}>();
+
+// ▼ しきい値 ▼ //
+
+// スワイプと判定される最小の距離
+const MIN_SWIPE_DISTANCE = 50;
+
+// スワイプ時の動作を発火する最小の距離
+const SWIPE_DISTANCE_THRESHOLD = 125;
+
+// スワイプを中断するY方向の移動距離
+const SWIPE_ABORT_Y_THRESHOLD = 75;
+
+// スワイプできる最大の距離
+const MAX_SWIPE_DISTANCE = 150;
+
+// ▲ しきい値 ▲ //
+
+let startScreenX: number | null = null;
+let startScreenY: number | null = null;
+
+const currentTabIndex = computed(() => props.tabs.findIndex(tab => tab.key === tabModel.value));
+
+const pullDistance = ref(0);
+const isSwiping = ref(false);
+const isSwipingForClass = ref(false);
+let swipeAborted = false;
+
+function touchStart(event: TouchEvent) {
+ if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return;
+
+ if (event.touches.length !== 1) return;
+
+ startScreenX = event.touches[0].screenX;
+ startScreenY = event.touches[0].screenY;
+}
+
+function touchMove(event: TouchEvent) {
+ if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return;
+
+ if (event.touches.length !== 1) return;
+
+ if (startScreenX == null || startScreenY == null) return;
+
+ if (swipeAborted) return;
+
+ let distanceX = event.touches[0].screenX - startScreenX;
+ let distanceY = event.touches[0].screenY - startScreenY;
+
+ if (Math.abs(distanceY) > SWIPE_ABORT_Y_THRESHOLD) {
+ swipeAborted = true;
+
+ pullDistance.value = 0;
+ isSwiping.value = false;
+ setTimeout(() => {
+ isSwipingForClass.value = false;
+ }, 400);
+
+ return;
+ }
+
+ if (Math.abs(distanceX) < MIN_SWIPE_DISTANCE) return;
+ if (Math.abs(distanceX) > MAX_SWIPE_DISTANCE) return;
+
+ if (currentTabIndex.value === 0 || props.tabs[currentTabIndex.value - 1].onClick) {
+ distanceX = Math.min(distanceX, 0);
+ }
+ if (currentTabIndex.value === props.tabs.length - 1 || props.tabs[currentTabIndex.value + 1].onClick) {
+ distanceX = Math.max(distanceX, 0);
+ }
+ if (distanceX === 0) return;
+
+ isSwiping.value = true;
+ isSwipingForClass.value = true;
+ nextTick(() => {
+ // グリッチを控えるため、1.5px以上の差がないと更新しない
+ if (Math.abs(distanceX - pullDistance.value) < 1.5) return;
+ pullDistance.value = distanceX;
+ });
+}
+
+function touchEnd(event: TouchEvent) {
+ if (swipeAborted) {
+ swipeAborted = false;
+ return;
+ }
+
+ if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return;
+
+ if (event.touches.length !== 0) return;
+
+ if (startScreenX == null) return;
+
+ if (!isSwiping.value) return;
+
+ const distance = event.changedTouches[0].screenX - startScreenX;
+
+ if (Math.abs(distance) > SWIPE_DISTANCE_THRESHOLD) {
+ if (distance > 0) {
+ if (props.tabs[currentTabIndex.value - 1] && !props.tabs[currentTabIndex.value - 1].onClick) {
+ tabModel.value = props.tabs[currentTabIndex.value - 1].key;
+ emit('swiped', props.tabs[currentTabIndex.value - 1].key, 'right');
+ }
+ } else {
+ if (props.tabs[currentTabIndex.value + 1] && !props.tabs[currentTabIndex.value + 1].onClick) {
+ tabModel.value = props.tabs[currentTabIndex.value + 1].key;
+ emit('swiped', props.tabs[currentTabIndex.value + 1].key, 'left');
+ }
+ }
+ }
+
+ pullDistance.value = 0;
+ isSwiping.value = false;
+ setTimeout(() => {
+ isSwipingForClass.value = false;
+ }, 400);
+}
+
+const transitionName = ref<'swipeAnimationLeft' | 'swipeAnimationRight' | undefined>(undefined);
+
+watch(tabModel, (newTab, oldTab) => {
+ const newIndex = props.tabs.findIndex(tab => tab.key === newTab);
+ const oldIndex = props.tabs.findIndex(tab => tab.key === oldTab);
+
+ if (oldIndex >= 0 && newIndex && oldIndex < newIndex) {
+ transitionName.value = 'swipeAnimationLeft';
+ } else {
+ transitionName.value = 'swipeAnimationRight';
+ }
+
+ window.setTimeout(() => {
+ transitionName.value = undefined;
+ }, 400);
+});
+</script>
+
+<style lang="scss" module>
+.transitionRoot.enableAnimation {
+ display: grid;
+ overflow: clip;
+
+ .transitionChildren {
+ grid-area: 1 / 1 / 2 / 2;
+ transform: translateX(var(--swipe));
+
+ &.swipeAnimation_enterActive,
+ &.swipeAnimation_leaveActive {
+ transition: transform .3s cubic-bezier(0.65, 0.05, 0.36, 1);
+ }
+
+ &.swipeAnimationRight_leaveTo,
+ &.swipeAnimationLeft_enterFrom {
+ transform: translateX(calc(100% + 24px));
+ }
+
+ &.swipeAnimationRight_enterFrom,
+ &.swipeAnimationLeft_leaveTo {
+ transform: translateX(calc(-100% - 24px));
+ }
+ }
+}
+
+.swiping {
+ transition: transform .2s ease-out;
+}
+</style>