388 lines
13 KiB
TypeScript
388 lines
13 KiB
TypeScript
import Sign from "@/components/駅名表/Sign";
|
|
import { SpotSign } from "@/components/SpotSign/SpotSign";
|
|
import React, { useCallback, useEffect, useRef, useState, useMemo } from "react";
|
|
import { AS } from "@/storageControl";
|
|
import {
|
|
useWindowDimensions,
|
|
View,
|
|
LayoutAnimation,
|
|
TouchableOpacity,
|
|
Text,
|
|
ScrollView,
|
|
} from "react-native";
|
|
import Carousel, { ICarouselInstance } from "react-native-reanimated-carousel";
|
|
import { SheetManager } from "react-native-actions-sheet";
|
|
import { StationNumber } from "../StationPagination";
|
|
import { SimpleDot } from "../SimpleDot";
|
|
import { useThemeColors } from "@/lib/theme";
|
|
import { useResponsive } from "@/lib/responsive";
|
|
import Sortable from "react-native-sortables";
|
|
import Animated, {
|
|
FadeIn,
|
|
FadeOut,
|
|
runOnJS,
|
|
useAnimatedStyle,
|
|
useSharedValue,
|
|
withTiming,
|
|
} from "react-native-reanimated";
|
|
import { useSortMode } from "./useSortMode";
|
|
import { StationSource } from "@/types";
|
|
|
|
export const CarouselBox = ({
|
|
originalStationList,
|
|
listUpStation,
|
|
nearPositionStation,
|
|
setListIndex,
|
|
listIndex,
|
|
navigate,
|
|
stationSource,
|
|
}: {
|
|
originalStationList: any;
|
|
listUpStation: any[][];
|
|
nearPositionStation: any[][];
|
|
setListIndex: (i: number) => void;
|
|
listIndex: number;
|
|
navigate: any;
|
|
stationSource: StationSource;
|
|
}) => {
|
|
const carouselRef = useRef<ICarouselInstance>(null);
|
|
const { width } = useWindowDimensions();
|
|
const { colors, fixed } = useThemeColors();
|
|
const { fontScale } = useResponsive();
|
|
const [dotButton, setDotButton] = useState(false);
|
|
const carouselBadgeScrollViewRef = useRef<ScrollView>(null);
|
|
// listIndex が -1 になってもカルーセルが表示中は直前の値を維持する
|
|
const lastValidListIndexRef = useRef(0);
|
|
if (listIndex >= 0) lastValidListIndexRef.current = listIndex;
|
|
|
|
// グリッド定数(ソートモードと座標計算で共用)
|
|
const {
|
|
origW,
|
|
origH,
|
|
cols,
|
|
gridPad,
|
|
gridGap,
|
|
cellW,
|
|
cellH,
|
|
carouselHeight,
|
|
rows,
|
|
gridHeight,
|
|
} = useMemo(() => {
|
|
const origW = width * 0.8;
|
|
const origH = (origW / 20) * 9;
|
|
const cols = 3;
|
|
const gridPad = 8;
|
|
const gridGap = 8;
|
|
const cellW = (width - gridPad * 2 - gridGap * (cols - 1)) / cols;
|
|
const cellH = (cellW / origW) * origH;
|
|
const carouselHeight = origH + 10;
|
|
const rows = Math.ceil(listUpStation.length / cols);
|
|
const gridHeight = rows * cellH + Math.max(0, rows - 1) * gridGap + gridPad * 2;
|
|
|
|
return { origW, origH, cols, gridPad, gridGap, cellW, cellH, carouselHeight, rows, gridHeight };
|
|
}, [width, listUpStation.length]);
|
|
|
|
const {
|
|
uiMode,
|
|
startSortMode,
|
|
exitSortMode,
|
|
sortGridRenderItem,
|
|
onSortDragEnd,
|
|
} = useSortMode({
|
|
listUpStation,
|
|
setListIndex,
|
|
width,
|
|
origW,
|
|
origH,
|
|
cols,
|
|
gridPad,
|
|
gridGap,
|
|
cellW,
|
|
cellH,
|
|
carouselHeight,
|
|
stationSource,
|
|
});
|
|
|
|
// ソートモード中かどうか
|
|
const isSortMode = uiMode !== "carousel";
|
|
|
|
// コンテナ高さ(カルーセル ↔ グリッドで可変)
|
|
const containerHeight = useSharedValue(carouselHeight);
|
|
const containerHeightStyle = useAnimatedStyle(() => ({ height: containerHeight.value }));
|
|
|
|
// ドットエリアのフェード
|
|
const dotsOpacity = useSharedValue(1);
|
|
const dotsAnimStyle = useAnimatedStyle(() => ({ opacity: dotsOpacity.value }));
|
|
|
|
// カルーセル ↔ グリッドのフェード
|
|
const [isGridMounted, setIsGridMounted] = useState(false);
|
|
const carouselOpacity = useSharedValue(1);
|
|
const gridOpacity = useSharedValue(0);
|
|
const carouselAnimStyle = useAnimatedStyle(() => ({ opacity: carouselOpacity.value }));
|
|
const gridAnimStyle = useAnimatedStyle(() => ({ opacity: gridOpacity.value }));
|
|
|
|
useEffect(() => {
|
|
const duration = 250;
|
|
if (isSortMode) {
|
|
setIsGridMounted(true); // フェードイン前にマウント
|
|
dotsOpacity.value = withTiming(0, { duration });
|
|
carouselOpacity.value = withTiming(0, { duration });
|
|
gridOpacity.value = withTiming(1, { duration });
|
|
containerHeight.value = withTiming(gridHeight, { duration });
|
|
} else {
|
|
dotsOpacity.value = withTiming(1, { duration });
|
|
carouselOpacity.value = withTiming(1, { duration });
|
|
containerHeight.value = withTiming(carouselHeight, { duration });
|
|
gridOpacity.value = withTiming(0, { duration }, (finished) => {
|
|
if (finished) runOnJS(setIsGridMounted)(false); // フェードアウト完了後にアンマウント
|
|
});
|
|
}
|
|
}, [isSortMode, gridHeight, carouselHeight]);
|
|
|
|
// ソートモード終了直後フラグ(次の listIndex 変更でアニメーションをスキップ)
|
|
const justExitedSortRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (!isSortMode) {
|
|
justExitedSortRef.current = true;
|
|
}
|
|
}, [isSortMode]);
|
|
|
|
// バッジからのインデックス変更をカルーセルに反映
|
|
useEffect(() => {
|
|
if (listIndex >= 0 && carouselRef.current) {
|
|
const animated = !justExitedSortRef.current;
|
|
justExitedSortRef.current = false;
|
|
carouselRef.current.scrollTo({ index: listIndex, animated });
|
|
}
|
|
}, [listIndex]);
|
|
|
|
// ドットのスクロール追従
|
|
useEffect(() => {
|
|
if (!carouselBadgeScrollViewRef.current) return;
|
|
const dotSize = dotButton ? 28 : 24;
|
|
const scrollToIndex = dotSize * listIndex - width / 2 + dotSize - 5;
|
|
carouselBadgeScrollViewRef.current.scrollTo({ x: scrollToIndex, animated: true });
|
|
}, [listIndex, dotButton, width]);
|
|
|
|
// ドット表示設定の読み込み
|
|
useEffect(() => {
|
|
AS.getItem("CarouselSettings/activeDotSettings").then((data) => {
|
|
setDotButton(data === "true");
|
|
});
|
|
}, []);
|
|
|
|
const oPSign = () => {
|
|
const payload = {
|
|
currentStation: listUpStation[listIndex],
|
|
navigate,
|
|
goTo: "menu",
|
|
//@ts-ignore
|
|
useShow: () => SheetManager.show("StationDetailView", { payload }),
|
|
onExit: () => SheetManager.hide("StationDetailView"),
|
|
};
|
|
//@ts-ignore
|
|
SheetManager.show("StationDetailView", { payload });
|
|
};
|
|
|
|
const oLPSign = () => {
|
|
// 駅がある場合はどのモードでもグリッドビューに切り替える
|
|
if (
|
|
listUpStation.length > 0 &&
|
|
listUpStation[0][0].StationNumber !== "null"
|
|
) {
|
|
startSortMode(listIndex);
|
|
return;
|
|
}
|
|
// 駅なし:長押しでドット表示切り替え
|
|
LayoutAnimation.configureNext({
|
|
duration: 600,
|
|
update: { type: "spring", springDamping: 0.5 },
|
|
});
|
|
AS.setItem("CarouselSettings/activeDotSettings", !dotButton ? "true" : "false");
|
|
setDotButton(!dotButton);
|
|
};
|
|
|
|
const RenderItem = useCallback(
|
|
({ item }) => (
|
|
<View
|
|
style={{ backgroundColor: "#0000", width, flexDirection: "row" }}
|
|
key={item[0].StationNumber ?? item[0].Station_JP}
|
|
>
|
|
<View style={{ flex: 1 }} />
|
|
{item[0].isSpot ? (
|
|
<SpotSign
|
|
item={item}
|
|
isCurrentStation={item == nearPositionStation}
|
|
/>
|
|
) : item[0].StationNumber != "null" ? (
|
|
<Sign
|
|
stationID={item[0].StationNumber}
|
|
isCurrentStation={item == nearPositionStation}
|
|
oP={oPSign}
|
|
oLP={oLPSign}
|
|
/>
|
|
) : (
|
|
<TouchableOpacity
|
|
style={{
|
|
width: width * 0.8,
|
|
height: ((width * 0.8) / 20) * 9,
|
|
borderColor: fixed.primary,
|
|
borderWidth: 1,
|
|
backgroundColor: colors.background,
|
|
}}
|
|
>
|
|
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
|
<Text style={{ color: colors.textAccent, fontSize: fontScale(20) }}>
|
|
{stationSource.type === "search"
|
|
? (stationSource.query || stationSource.lineId)
|
|
? "該当する駅が見つかりませんでした。"
|
|
: "駅名・ナンバリングを入力するか、路線を選んでください。"
|
|
: stationSource.type === "position"
|
|
? "現在地の近くに駅がありません。"
|
|
: "お気に入りリストがありません。お気に入りの駅を追加しよう!"}
|
|
</Text>
|
|
</View>
|
|
</TouchableOpacity>
|
|
)}
|
|
<View style={{ flex: 1 }} />
|
|
</View>
|
|
),
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[width, nearPositionStation, oPSign, oLPSign, stationSource]
|
|
);
|
|
|
|
return (
|
|
<View style={{ flex: 1, paddingTop: 10 }}>
|
|
{/* カルーセル / グリッド(同じ高さ領域を共用・クロスフェード) */}
|
|
<Animated.View style={[{ overflow: "visible" }, containerHeightStyle]}>
|
|
{/* カルーセル */}
|
|
<Animated.View
|
|
style={[{ position: "absolute", width }, carouselAnimStyle]}
|
|
pointerEvents={isSortMode ? "none" : "auto"}
|
|
>
|
|
<Carousel
|
|
ref={carouselRef}
|
|
data={listUpStation.length > 0 ? listUpStation : [[{ StationNumber: "null" }]]}
|
|
height={carouselHeight}
|
|
pagingEnabled={true}
|
|
snapEnabled={true}
|
|
loop={false}
|
|
width={width}
|
|
style={{ width, alignContent: "center" }}
|
|
mode="parallax"
|
|
modeConfig={{
|
|
parallaxScrollingScale: 1,
|
|
parallaxScrollingOffset: 100,
|
|
parallaxAdjacentItemScale: 0.8,
|
|
}}
|
|
scrollAnimationDuration={600}
|
|
onSnapToItem={setListIndex}
|
|
renderItem={RenderItem}
|
|
overscrollEnabled={false}
|
|
defaultIndex={
|
|
lastValidListIndexRef.current >= listUpStation.length
|
|
? 0
|
|
: lastValidListIndexRef.current
|
|
}
|
|
/>
|
|
</Animated.View>
|
|
|
|
{/* グリッド:ソートモード中のみマウント */}
|
|
{isGridMounted && (
|
|
<Animated.View
|
|
style={[
|
|
{ position: "absolute", width, height: gridHeight, paddingHorizontal: gridPad, overflow: "visible" },
|
|
gridAnimStyle,
|
|
]}
|
|
>
|
|
<Sortable.Grid
|
|
columns={cols}
|
|
columnGap={gridGap}
|
|
rowGap={gridGap}
|
|
data={listUpStation}
|
|
renderItem={sortGridRenderItem}
|
|
keyExtractor={(item) => item[0].StationNumber ?? item[0].Station_JP}
|
|
onDragEnd={onSortDragEnd}
|
|
sortEnabled={stationSource.type === "favorite"}
|
|
/>
|
|
</Animated.View>
|
|
)}
|
|
</Animated.View>
|
|
|
|
{/* ドットエリア:ソートモード時はフェードアウト */}
|
|
<Animated.View style={dotsAnimStyle} pointerEvents={isSortMode ? "none" : "auto"}>
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={{
|
|
flexDirection: "row",
|
|
justifyContent: "center",
|
|
alignContent: "center",
|
|
alignItems: "center",
|
|
paddingVertical: 2,
|
|
paddingHorizontal: 10,
|
|
minWidth: width,
|
|
}}
|
|
ref={(scrollViewRef) => {
|
|
if (scrollViewRef) {
|
|
carouselBadgeScrollViewRef.current = scrollViewRef;
|
|
}
|
|
}}
|
|
>
|
|
{originalStationList &&
|
|
listUpStation.map((d, index) => {
|
|
const active = index == listIndex;
|
|
const numberKey = d[0].StationNumber + index;
|
|
return dotButton && d[0].StationNumber ? (
|
|
<StationNumber
|
|
onPress={() => setListIndex(index)}
|
|
currentStation={d}
|
|
active={active}
|
|
key={numberKey}
|
|
/>
|
|
) : (
|
|
<SimpleDot
|
|
onPress={() => setListIndex(index)}
|
|
active={active}
|
|
key={numberKey}
|
|
/>
|
|
);
|
|
})}
|
|
</ScrollView>
|
|
</Animated.View>
|
|
|
|
{/* 並び替えコントロール:ソートモード時に最下部からスライドイン */}
|
|
{isSortMode && (
|
|
<Animated.View
|
|
entering={FadeIn.duration(200)}
|
|
exiting={FadeOut.duration(150)}
|
|
style={{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
paddingHorizontal: 16,
|
|
paddingTop: 6,
|
|
paddingBottom: 4,
|
|
}}
|
|
>
|
|
<Text style={{ flex: 1, color: colors.textAccent, fontSize: fontScale(14) }}>
|
|
{stationSource.type === "favorite" ? "長押しでドラッグして並び替え" : "タップして駅を選択"}
|
|
</Text>
|
|
<TouchableOpacity
|
|
onPress={exitSortMode}
|
|
disabled={uiMode === "sort-exiting"}
|
|
style={{
|
|
backgroundColor: uiMode === "sort-exiting" ? "#88c8e8" : fixed.primary,
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 6,
|
|
borderRadius: 16,
|
|
}}
|
|
>
|
|
<Text style={{ color: fixed.textOnPrimary, fontWeight: "bold" }}>完了</Text>
|
|
</TouchableOpacity>
|
|
</Animated.View>
|
|
)}
|
|
</View>
|
|
);
|
|
};
|