Files
jrshikoku/components/Menu/Carousel/CarouselBox.tsx

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>
);
};