|
1 | 1 | import { darken } from 'polished'; |
2 | | -import React, { useCallback, useEffect, useMemo } from 'react'; |
3 | | -import { StyleSheet, useWindowDimensions, View } from 'react-native'; |
4 | | -import Animated, { |
5 | | - cancelAnimation, |
6 | | - useAnimatedStyle, |
7 | | - useSharedValue, |
8 | | - withRepeat, |
9 | | - withSequence, |
10 | | - withTiming, |
11 | | -} from 'react-native-reanimated'; |
| 2 | +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; |
| 3 | +import { |
| 4 | + Animated, |
| 5 | + Easing, |
| 6 | + StyleSheet, |
| 7 | + useWindowDimensions, |
| 8 | + View, |
| 9 | +} from 'react-native'; |
12 | 10 | import { Path, Svg } from 'react-native-svg'; |
13 | 11 | import type { Line, LineNested, Station } from '~/@types/graphql'; |
14 | 12 | import { isBusLine } from '~/utils/line'; |
@@ -415,71 +413,81 @@ const PadArch: React.FC<Props> = ({ |
415 | 413 | }: Props) => { |
416 | 414 | const { width: windowWidth, height: windowHeight } = useWindowDimensions(); |
417 | 415 |
|
418 | | - // 共有値(Reanimated) |
419 | | - const bgScale = useSharedValue(0.95); |
420 | | - // シェブロンのアニメーションは 0..1 の単一タイムラインで駆動 |
421 | | - const chevronTimeline = useSharedValue(0); |
422 | | - const fillHeight = useSharedValue(0); |
| 416 | + // Animated.Value(RN Animated API — Reanimated 4.2 の mapper バグ回避) |
| 417 | + const bgScale = useRef(new Animated.Value(0.95)).current; |
| 418 | + const chevronTimeline = useRef(new Animated.Value(0)).current; |
| 419 | + const fillHeight = useRef(new Animated.Value(0)).current; |
423 | 420 |
|
424 | 421 | // エフェクト: シェブロンと背景のアニメーション制御 |
425 | | - // biome-ignore lint/correctness/useExhaustiveDependencies: SharedValue は安定した参照のため依存配列に含めません |
426 | 422 | useEffect(() => { |
427 | | - // 既存のアニメーションを停止してから新しいアニメーションを開始 |
428 | | - cancelAnimation(bgScale); |
429 | | - cancelAnimation(chevronTimeline); |
430 | | - |
431 | 423 | if (arrived) { |
432 | | - // 背景スケールを鼓動させる |
433 | | - bgScale.value = withRepeat( |
434 | | - withSequence( |
435 | | - withTiming(0.8, { duration: YAMANOTE_CHEVRON_SCALE_DURATION }), |
436 | | - withTiming(0.95, { duration: YAMANOTE_CHEVRON_SCALE_DURATION }) |
437 | | - ), |
438 | | - -1, |
439 | | - false |
440 | | - ); |
| 424 | + chevronTimeline.stopAnimation(); |
| 425 | + chevronTimeline.setValue(0); |
| 426 | + Animated.loop( |
| 427 | + Animated.sequence([ |
| 428 | + Animated.timing(bgScale, { |
| 429 | + toValue: 0.8, |
| 430 | + duration: YAMANOTE_CHEVRON_SCALE_DURATION, |
| 431 | + easing: Easing.inOut(Easing.ease), |
| 432 | + useNativeDriver: true, |
| 433 | + }), |
| 434 | + Animated.timing(bgScale, { |
| 435 | + toValue: 0.95, |
| 436 | + duration: YAMANOTE_CHEVRON_SCALE_DURATION, |
| 437 | + easing: Easing.inOut(Easing.ease), |
| 438 | + useNativeDriver: true, |
| 439 | + }), |
| 440 | + ]) |
| 441 | + ).start(); |
441 | 442 | } else { |
442 | | - // タイムラインは2フェーズ(移動→フェード)でループ(合計 2x の所要時間) |
443 | | - chevronTimeline.value = 0; |
444 | | - chevronTimeline.value = withRepeat( |
445 | | - withSequence( |
446 | | - withTiming(1, { duration: YAMANOTE_CHEVRON_MOVE_DURATION * 2 }), |
447 | | - withTiming(0, { duration: 0 }) |
448 | | - ), |
449 | | - -1, |
450 | | - false |
451 | | - ); |
| 443 | + bgScale.stopAnimation(); |
| 444 | + bgScale.setValue(0.95); |
| 445 | + Animated.loop( |
| 446 | + Animated.sequence([ |
| 447 | + Animated.timing(chevronTimeline, { |
| 448 | + toValue: 1, |
| 449 | + duration: YAMANOTE_CHEVRON_MOVE_DURATION * 2, |
| 450 | + easing: Easing.linear, |
| 451 | + useNativeDriver: false, |
| 452 | + }), |
| 453 | + Animated.timing(chevronTimeline, { |
| 454 | + toValue: 0, |
| 455 | + duration: 0, |
| 456 | + useNativeDriver: false, |
| 457 | + }), |
| 458 | + ]) |
| 459 | + ).start(); |
452 | 460 | } |
453 | | - }, [arrived]); |
| 461 | + return () => { |
| 462 | + bgScale.stopAnimation(); |
| 463 | + chevronTimeline.stopAnimation(); |
| 464 | + }; |
| 465 | + }, [arrived, bgScale, chevronTimeline]); |
454 | 466 |
|
455 | | - // エフェクト: マウント時と到着/出発切替またはウィンドウサイズ変更ごとに塗りつぶしアニメーション |
456 | | - // biome-ignore lint/correctness/useExhaustiveDependencies: SharedValue は安定した参照のため依存配列に含めません |
| 467 | + // エフェクト: 塗りつぶしアニメーション(arrived 切替時にもリセットしたいため依存に含める) |
| 468 | + // biome-ignore lint/correctness/useExhaustiveDependencies: arrived は値変化時にアニメーションを再開するため必要 |
457 | 469 | useEffect(() => { |
458 | | - fillHeight.value = 0; |
459 | | - fillHeight.value = withTiming(windowHeight, { |
| 470 | + fillHeight.setValue(0); |
| 471 | + Animated.timing(fillHeight, { |
| 472 | + toValue: windowHeight, |
460 | 473 | duration: YAMANOTE_LINE_BOARD_FILL_DURATION, |
461 | | - }); |
| 474 | + easing: Easing.out(Easing.ease), |
| 475 | + useNativeDriver: false, |
| 476 | + }).start(); |
| 477 | + return () => { |
| 478 | + fillHeight.stopAnimation(); |
| 479 | + }; |
462 | 480 | }, [arrived, fillHeight, windowHeight]); |
463 | 481 |
|
464 | | - // アニメーション用スタイル |
465 | | - const fillStyle = useAnimatedStyle(() => ({ height: fillHeight.value })); |
466 | | - const chevronContainerStyle = useAnimatedStyle(() => { |
467 | | - if (arrived) return {}; |
468 | | - const p = chevronTimeline.value; // サイクル全体で 0..1 の進行度 |
469 | | - // 前半(0..0.5): 上方向に 24px 移動、後半は維持 |
470 | | - const movePhase = Math.min(p / 0.5, 1); // 前半中は 0..1 |
471 | | - // 後半(0.5..1): 不透明度 1 → 0.2、前半は 1 を維持 |
472 | | - const fadePhase = Math.max((p - 0.5) / 0.5, 0); // 後半中は 0..1 |
473 | | - const opacity = 0.2 + (1 - fadePhase) * 0.8; // 0.2..1 の範囲 |
474 | | - const translateY = -movePhase * 24; |
475 | | - return { |
476 | | - // 既定の rotate(-20deg) を維持したまま並記(transform は配列全体が上書きされるためここで回転も指定) |
477 | | - transform: [{ rotate: '-20deg' }, { translateY }], |
478 | | - opacity, |
479 | | - }; |
| 482 | + // シェブロン用の補間スタイル(非到着時のみ使用) |
| 483 | + const chevronOpacity = chevronTimeline.interpolate({ |
| 484 | + inputRange: [0, 0.5, 1], |
| 485 | + outputRange: [1, 1, 0.2], |
| 486 | + }); |
| 487 | + const chevronTranslateY = chevronTimeline.interpolate({ |
| 488 | + inputRange: [0, 0.5, 1], |
| 489 | + outputRange: [0, -24, -24], |
480 | 490 | }); |
481 | | - |
482 | | - // AnimatedChevron不要。SharedValueを直接渡す |
483 | 491 |
|
484 | 492 | const paths = useMemo( |
485 | 493 | () => ({ |
@@ -627,7 +635,11 @@ const PadArch: React.FC<Props> = ({ |
627 | 635 |
|
628 | 636 | {/* 暗色層: 区間ごとにViewクリッピングで色分け */} |
629 | 637 | <Animated.View |
630 | | - style={[styles.clipViewStyle, dynamicStyles.clipViewStyle, fillStyle]} |
| 638 | + style={[ |
| 639 | + styles.clipViewStyle, |
| 640 | + dynamicStyles.clipViewStyle, |
| 641 | + { height: fillHeight }, |
| 642 | + ]} |
631 | 643 | > |
632 | 644 | {colorSegments.map((seg) => ( |
633 | 645 | <View |
@@ -660,7 +672,11 @@ const PadArch: React.FC<Props> = ({ |
660 | 672 | </Animated.View> |
661 | 673 | {/* 主色層: 区間ごとにViewクリッピングで色分け */} |
662 | 674 | <Animated.View |
663 | | - style={[styles.clipViewStyle, dynamicStyles.clipViewStyle, fillStyle]} |
| 675 | + style={[ |
| 676 | + styles.clipViewStyle, |
| 677 | + dynamicStyles.clipViewStyle, |
| 678 | + { height: fillHeight }, |
| 679 | + ]} |
664 | 680 | > |
665 | 681 | {colorSegments.map((seg) => ( |
666 | 682 | <View |
@@ -698,10 +714,16 @@ const PadArch: React.FC<Props> = ({ |
698 | 714 | dynamicStyles.chevron, |
699 | 715 | arrived |
700 | 716 | ? [styles.chevronArrived, dynamicStyles.chevronArrived] |
701 | | - : chevronContainerStyle, |
| 717 | + : { |
| 718 | + transform: [ |
| 719 | + { rotate: '-20deg' }, |
| 720 | + { translateY: chevronTranslateY }, |
| 721 | + ], |
| 722 | + opacity: chevronOpacity, |
| 723 | + }, |
702 | 724 | ]} |
703 | 725 | > |
704 | | - <ChevronYamanote backgroundScaleSV={bgScale} arrived={arrived} /> |
| 726 | + <ChevronYamanote backgroundScaleAV={bgScale} arrived={arrived} /> |
705 | 727 | </Animated.View> |
706 | 728 |
|
707 | 729 | <View style={styles.stationNames}> |
|
0 commit comments