as

Settings
Sign out
Notifications
Alexa
Amazonアプリストア
Ring
AWS
ドキュメント
Support
Contact Us
My Cases
開発
設計と開発
公開
リファレンス
サポート

Vegaで対話型のトースト通知を実装する方法

Vegaで対話型のトースト通知を実装する方法

この記事では、利用可能なコンテンツをユーザーに通知し、再生/一時停止ボタンを長押しすることでストリーミングを直接開始できる、興味を引くトースト通知システムを実装します。このソリューションは、Vega Videoサンプルアプリを変更して、TV向けに最適化された対話型のトーストを実装する方法を示しています。トーストは、視聴エクスペリエンスを妨げずにアプリ内コンテンツを宣伝します。

前提条件

必要な依存関係のインストール

プロジェクトにトーストメッセージライブラリを追加します。

クリップボードにコピーしました。


npm install react-native-toast-message@^2.2.0

カスタムトーストコンポーネントの作成

ユーザーの操作中に進行状況バーを表示する、TV向けに最適化されたトーストコンポーネント用の新しいファイルを作成します。

src/components/ProgressToast.tsxを作成し、次の内容を実装します。

クリップボードにコピーしました。


import React from 'react';
import { View, Text, StyleSheet, Animated } from 'react-native';
import { BaseToast, ToastProps } from 'react-native-toast-message';

interface ProgressToastProps extends ToastProps {
  props?: {
    progress?: number;
    isHolding?: boolean;
  };
}

export const ProgressToast: React.FC<ProgressToastProps> = ({ props, text1, text2, ...rest }) => {
  const progress = props?.progress ?? 0;
  const isHolding = props?.isHolding ?? false;

  return (
    <View style={styles.toastContainer}>
      <Text style={styles.instructionText}>
        {text1 || '開始するには ⏯️ を長押ししてください...'}
      </Text>
      
      {text2 && (
        <Text style={styles.secondaryText}>
          {text2}
        </Text>
      )}

      <View style={styles.progressBackground}>
        <Animated.View
          style={[
            styles.progressFill,
            {
              width: `${progress * 100}%`,
              backgroundColor: isHolding ? '#00ff00' : '#007bff',
            }
          ]}
        />
      </View>
    </View>
  );
};

const styles = StyleSheet.create({
  toastContainer: {
    position: 'absolute',
    top: '20%',
    right: '2%',
    width: 480,
    backgroundColor: 'rgba(26, 26, 26, 0.95)',
    borderRadius: 8,
    padding: 16,
    shadowColor: '#000',
    shadowOffset: { width: 2, height: 2 },
    shadowOpacity: 0.8,
    shadowRadius: 6,
    elevation: 10,
    borderWidth: 1,
    borderColor: 'rgba(0, 123, 255, 0.3)',
  },
  instructionText: {
    fontSize: 22,
    fontWeight: '600',
    color: '#ffffff',
    textAlign: 'center',
    marginBottom: 8,
    textShadowColor: 'rgba(0, 0, 0, 0.8)',
    textShadowOffset: { width: 1, height: 1 },
    textShadowRadius: 2,
  },
  secondaryText: {
    fontSize: 20,
    fontWeight: '500',
    color: '#e0e0e0',
    textAlign: 'center',
    marginBottom: 12,
    textShadowColor: 'rgba(0, 0, 0, 0.8)',
    textShadowOffset: { width: 1, height: 1 },
    textShadowRadius: 2,
  },
  progressBackground: {
    width: '100%',
    height: 12,
    backgroundColor: '#333333',
    borderRadius: 6,
    overflow: 'hidden',
    marginTop: 4,
  },
  progressFill: {
    height: '100%',
    borderRadius: 6,
  },
});

export const toastConfig = {
  progress: (props: ProgressToastProps) => <ProgressToast {...props} />,
};

このコンポーネントは、画面の右側に長方形のトーストを作成し、進行状況をリアルタイムでフィードバックします。ユーザーがボタンを長押しすると、進行状況バーが青から緑に変わります。

長押しフックの作成

再生/一時停止ボタンの操作とトーストの表示のライフサイクル全体を管理するカスタムフックを作成します。

src/hooks/useLongPressToast.tsxを作成し、次の内容を実装します。

クリップボードにコピーしました。


import { useState, useRef, useCallback, useEffect } from 'react';
import { useTVEventHandler } from '@amazon-devices/react-native-kepler';
import Toast from 'react-native-toast-message';

export type TVButtonType = 'playpause' | 'select' | 'menu' | 'back' | 'up' | 'down' | 'left' | 'right';

interface UseLongPressToastProps {
  onLongPressComplete: () => void;
  targetButton?: TVButtonType;
  autoShowDelay?: number;
  longPressDuration?: number;
  toastTimeout?: number;
  initialText?: string;
  holdingText?: string;
  secondaryText?: string;
  completionText?: string;
  isScreenFocused?: boolean;
}

interface LongPressState {
  isHolding: boolean;
  progress: number;
  toastVisible: boolean;
  hasAutoShown: boolean;
  hasCompleted: boolean;
}

export const useLongPressToast = ({ 
  onLongPressComplete, 
  targetButton = 'playpause',
  autoShowDelay = 5000,
  longPressDuration = 2000,
  toastTimeout = 5000,
  initialText = 'ボタンを長押しして続行',
  holdingText = '押し続けてください...',
  secondaryText,
  completionText = 'アクション完了',
  isScreenFocused = true
}: UseLongPressToastProps) => {
  const [state, setState] = useState<LongPressState>({
    isHolding: false,
    progress: 0,
    toastVisible: false,
    hasAutoShown: false,
    hasCompleted: false,
  });

  const progressIntervalRef = useRef<NodeJS.Timeout | null>(null);
  const autoShowTimeoutRef = useRef<NodeJS.Timeout | null>(null);
  const toastTimeoutRef = useRef<NodeJS.Timeout | null>(null);
  const holdStartTimeRef = useRef<number>(0);
  const isProgressCompleteRef = useRef<boolean>(false);
  const toastVisibleRef = useRef<boolean>(false);

  const showInitialToast = useCallback(() => {
    if (state.toastVisible || state.hasAutoShown || state.hasCompleted) return;

    Toast.show({
      type: 'progress',
      text1: initialText,
      text2: secondaryText,
      visibilityTime: toastTimeout,
      autoHide: false,
      props: {
        progress: 0,
        isHolding: false,
      },
    });

    setState(prev => ({
      ...prev,
      toastVisible: true,
      hasAutoShown: true,
    }));

    toastVisibleRef.current = true;

    toastTimeoutRef.current = setTimeout(() => {
      hideToast();
    }, toastTimeout);
  }, [state.toastVisible, state.hasAutoShown, state.hasCompleted, toastTimeout, initialText, secondaryText]);

  const hideToast = useCallback(() => {
    if (toastTimeoutRef.current) {
      clearTimeout(toastTimeoutRef.current);
      toastTimeoutRef.current = null;
    }

    Toast.hide();
    setState(prev => ({
      ...prev,
      toastVisible: false,
    }));

    toastVisibleRef.current = false;
  }, []);

  const startProgress = useCallback(() => {
    if (state.isHolding) return;

    if (toastTimeoutRef.current) {
      clearTimeout(toastTimeoutRef.current);
      toastTimeoutRef.current = null;
    }

    holdStartTimeRef.current = Date.now();
    isProgressCompleteRef.current = false;

    setState(prev => ({
      ...prev,
      isHolding: true,
      progress: 0,
      toastVisible: true,
    }));

    toastVisibleRef.current = true;

    progressIntervalRef.current = setInterval(() => {
      const elapsed = Date.now() - holdStartTimeRef.current;
      const newProgress = Math.min(elapsed / longPressDuration, 1);

      setState(prev => ({
        ...prev,
        progress: newProgress,
      }));

      Toast.show({
        type: 'progress',
        text1: holdingText,
        text2: secondaryText,
        autoHide: false,
        props: {
          progress: newProgress,
          isHolding: true,
        },
      });

      if (newProgress >= 1 && !isProgressCompleteRef.current) {
        isProgressCompleteRef.current = true;
        completeProgress();
      }
    }, 50);
  }, [state.isHolding, longPressDuration, holdingText, secondaryText]);

  const stopProgress = useCallback(() => {
    if (!state.isHolding) return;

    if (progressIntervalRef.current) {
      clearInterval(progressIntervalRef.current);
      progressIntervalRef.current = null;
    }

    if (!isProgressCompleteRef.current) {
      hideToast();
    }

    setState(prev => ({
      ...prev,
      isHolding: false,
      progress: 0,
    }));
  }, [state.isHolding, hideToast]);

  const completeProgress = useCallback(() => {
    if (progressIntervalRef.current) {
      clearInterval(progressIntervalRef.current);
      progressIntervalRef.current = null;
    }

    if (completionText) {
      Toast.show({
        type: 'progress',
        text1: completionText,
        visibilityTime: 2000,
        props: {
          progress: 1,
          isHolding: false,
        },
      });
    }

    setState(prev => ({
      ...prev,
      isHolding: false,
      progress: 1,
      toastVisible: false,
      hasCompleted: true,
    }));

    toastVisibleRef.current = false;

    setTimeout(() => {
      onLongPressComplete();
    }, 500);
  }, [onLongPressComplete, completionText]);

  const handleTVEvent = useCallback((evt: any) => {
    if (evt.eventType !== targetButton || !toastVisibleRef.current) {
      return;
    }

    if (evt.eventKeyAction === 0) {
      startProgress();
    } else if (evt.eventKeyAction === 1) {
      stopProgress();
    }
  }, [startProgress, stopProgress, targetButton]);

  useTVEventHandler(handleTVEvent);

  useEffect(() => {
    if (!isScreenFocused) {
      return;
    }

    setState(prev => ({ ...prev, hasAutoShown: false }));

    autoShowTimeoutRef.current = setTimeout(() => {
      showInitialToast();
    }, autoShowDelay);

    return () => {
      if (autoShowTimeoutRef.current) {
        clearTimeout(autoShowTimeoutRef.current);
      }
    };
  }, [autoShowDelay, showInitialToast, isScreenFocused]);

  useEffect(() => {
    return () => {
      if (progressIntervalRef.current) {
        clearInterval(progressIntervalRef.current);
      }
      if (autoShowTimeoutRef.current) {
        clearTimeout(autoShowTimeoutRef.current);
      }
      if (toastTimeoutRef.current) {
        clearTimeout(toastTimeoutRef.current);
      }
    };
  }, []);

  return {
    isHolding: state.isHolding,
    progress: state.progress,
    toastVisible: state.toastVisible,
    hasAutoShown: state.hasAutoShown,
    showInitialToast,
    hideToast,
  };
};

このフックは、VegaのTVEventHandlerを使用して、トーストの自動表示のタイミング、進行状況のスムーズなアニメーション、コールバックによる完了の検出、すべてのタイマーとインターバルの適切なクリーンアップを管理します。

トーストコンポーネントの登録

メインのAppコンポーネントを更新して、カスタムトーストの構成を登録します。

src/App.tsxに次のインポートを追加します。

クリップボードにコピーしました。


import Toast from 'react-native-toast-message';
import { toastConfig } from './components/ProgressToast';

Appのreturnステートメント内にToastコンポーネントを追加します。

クリップボードにコピーしました。


const App = () => {
  return (
    <Provider store={store}>
      <ThemeProvider theme={theme}>
        <NavigationContainer>
          <AppStack />
        </NavigationContainer>
        <Toast config={toastConfig} />
      </ThemeProvider>
    </Provider>
  );
};

トーストと画面の統合

HomeScreenコンポーネントにトーストの長押しフックと画面フォーカスの追跡を追加するには、src/screens/HomeScreen.tsxに次のインポートを追加します。

クリップボードにコピーしました。


import { useLongPressToast } from '../hooks/useLongPressToast';
import { useIsFocused } from '@react-navigation/native';

HomeScreenコンポーネント内にフックを追加します。

クリップボードにコピーしました。


// アプリでtileDataとScreensが定義されていると想定します。
const HomeScreen = ({ navigation }: AppStackScreenProps<Screens.HOME_SCREEN>) => {
  const isFocused = useIsFocused();

  const navigateToStream = useCallback(() => {
    const params = {
      data: tileData,
      sendDataOnBack: () => {},
    };

    try {
      navigation.navigate(Screens.PLAYER_SCREEN, params);
    } catch (error) {
      console.error('ストリーミング画面に移動できませんでした:', error);
      navigation.goBack();
    }
  }, [navigation]);

  useLongPressToast({
    onLongPressComplete: navigateToStream,
    initialText: tileData.title + 'が間もなく開始',
    holdingText: '押し続けてください...',
    secondaryText: '開始するには ⏯️ を長押ししてください',
    completionText: 'ストリーミングを開始します',
    isScreenFocused: isFocused,
  });

  // ...既存の残りのHomeScreenコード
};

isScreenFocusedパラメーターは、HomeScreenが表示されている場合にのみトーストを表示するために使用されます。これにより、ほかの画面上にトーストが表示されるのを防ぎます。

実装のテスト

アプリをビルドし、Fire TVデバイスまたはエミュレーターで実行します。

  1. ホーム画面に移動します。
  2. トーストが自動的に表示されるまで5秒間待ちます。
  3. リモコンの再生/一時停止ボタンを長押しします。
  4. 進行状況バーが2秒かけて完了するのを確認します。
  5. 完了前にボタンを放すとキャンセルします。完了するまで押し続けるとナビゲーションがトリガーされます。

Last updated: 2026年3月10日