Skip to content

Commit

Permalink
feat(frontend): 横スワイプでタブを切り替える機能 (#13011)
Browse files Browse the repository at this point in the history
* (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 <[email protected]>

* use css module

---------

Co-authored-by: syuilo <[email protected]>
  • Loading branch information
kakkokari-gtyih and syuilo authored Jan 18, 2024
1 parent 67a41c0 commit c1019a0
Show file tree
Hide file tree
Showing 21 changed files with 682 additions and 411 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
- Feat: 絵文字の詳細ダイアログを追加
- Feat: 枠線をつけるMFM`$[border.width=1,style=solid,color=fff,radius=0 ...]`を追加
- デフォルトで枠線からはみ出る部分が隠されるようにしました。初期と同じ挙動にするには`$[border.noclip`が必要です
- Feat: スワイプでタブを切り替えられるように
- Enhance: MFM等のコードブロックに全文コピー用のボタンを追加
- Enhance: ハッシュタグ入力時に、本文の末尾の行に何も書かれていない場合は新たにスペースを追加しないように
- Enhance: チャンネルノートのピン留めをノートのメニューからできるように
Expand Down
1 change: 1 addition & 0 deletions locales/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1204,6 +1204,7 @@ export interface Locale {
"ranking": string;
"lastNDays": string;
"backToTitle": string;
"enableHorizontalSwipe": string;
"_bubbleGame": {
"howToPlay": string;
"_howToPlay": {
Expand Down
1 change: 1 addition & 0 deletions locales/ja-JP.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1201,6 +1201,7 @@ replaying: "リプレイ中"
ranking: "ランキング"
lastNDays: "直近{n}日"
backToTitle: "タイトルへ"
enableHorizontalSwipe: "スワイプしてタブを切り替える"

_bubbleGame:
howToPlay: "遊び方"
Expand Down
209 changes: 209 additions & 0 deletions packages/frontend/src/components/MkHorizontalSwipe.vue
Original file line number Diff line number Diff line change
@@ -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>
Loading

0 comments on commit c1019a0

Please sign in to comment.