Reactでトースト機能を実装してみる

# はじめに

Reactを勉強したアウトプットとして簡単なWebアプリを開発しました。
その際に、ユーザへのメッセージ表示としてトーストを表示させるコンポーネントを作成してみました。
コンポーネントとして完璧とは言えませんが、なにか参考になればと思い実装方法を記載します。
(Windows10 * Chrome でのみ動作確認をしているため、他の環境では正常に動くか不明です）

# トーストとは

次のサイトにおいてトーストは、「操作に関する簡単なフィードバックを小さなポップアップに表示します。トーストでは、メッセージの表示に必要なスペースのみを使用します。現在のアクティビティは表示されたままになり、引き続き操作できます。トーストはタイムアウト後に自動的に消えます。」と記載されています。[1]

https://developer.android.com/guide/topics/ui/notifiers/toasts?hl=ja

つまり、スマートフォンなどでなにか操作をしたときに、
下からひょっこり出てきてフェイドアウトしていくあれのことです。

# 実装したトーストの挙動

実装したトーストを実際に画面上で見るとこんな挙動になります。
（画質が荒いのと実際の挙動よりフェードイン・アウトが早く見えてますが、
なんとなくこんな感じかというのが理解できればOKです）

![trim.479A0676-EFF3-4B4E-B976-1382EFEB29C3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3031330/642b9043-dcf2-0aea-78e8-e5ac5efc1ccd.gif)

# ソースコード

次が実装したトーストのコンポーネントと関連するファイルです。
各ファイルは次のような役割を持たせています。
トーストのコンポーネントが準備できれば、各画面にてToastコンポーネントを使用します。
記事が長くなってしまうため、重要でない情報に関しては省略いたします。

- Toast.tsx
    - トーストのHTML要素を定義
    - アニメーション終了時の処理を定義
- difine.tsx
    - トーストの表示に関連する状態の値の初期値
        - 表示させるメッセージ (msg: string)
        - 表示・非表示の状態 (showable: boolean)
        - メッセージのステータス（status: 'info' or 'warn' or 'error'）
- Toast.css
    - トーストのスタイル定義
    - アニメーションの定義
- AppContext.tsx
    - useContextフックを使用したGlobalデータの保持
- index.tsx
    - アプリのエントリーポイント
    - AppContext.tsxによるGlobalデータの保持適応
    - 各画面へのルーティングの設定

``` JavaScript:Toast.tsx
import React, { useContext } from 'react';
import { SetToastContext, TosatContext } from '../context/AppContext';
import '../style/Toast.css'
import { initToastInfo } from '../config/define';

const Toast: React.FC = () => {
    const toastInfo = useContext(TosatContext);
    const setToastInfo = useContext(SetToastContext);

    return (
        <div
            id='toast-area'
            className={`toastArea is-${toastInfo.status} ${toastInfo.showable ? 'animation' : ''}`}
            onAnimationEnd={() => {setToastInfo(initToastInfo)}}>
                <div className={`toast-icon icon-${toastInfo.status}`}/>
                <p className={`toast-message`}>{toastInfo.msg}</p>
        </div>
    );
}

export default Toast;
```

```javascript:define.tsx
import { toastInfo } from "./typeDifine"

export const initToastInfo: toastInfo = {
    msg: '',
    showable: false,
    status: 'info',
}
```

```css:Toast.css
.toastArea {
    /*配置*/
    position: fixed;
    top: 100vh;
    left: 3vw;
    /*レイアウト*/
    width: 94vw;
    height: 5vh;
    border-radius: 10px;
    /*子要素の配置*/
    text-align: center;
    justify-content: center;
    display: flex;
    /*文字*/
    color: white;
    font-size: 1em;
}

.animation {
    animation-name: fadein;
    animation-duration: 5s;
    animation-delay: 0s;
    animation-fill-mode: forwards;
}

@keyframes fadein {
    0% { top: 100vh; }
    15% { top: 90vh; }
    85% { top: 90vh; }
    100% { top: 100vh; }
}

/* これ以降はトーストの状態における固有スタイルと子要素のスタイル設定のため省略 */
```
```javascript:AppContext.tsx
import React, { Dispatch, SetStateAction, useState } from "react";
import { toastInfo } from "../config/typeDifine";
import { initToastInfo } from "../config/define";

export const TosatContext = React.createContext(initToastInfo);
export const SetToastContext = React.createContext<Dispatch<SetStateAction<toastInfo>>>(() => undefined);

type Props = { children: React.ReactNode };

const AppContext: React.FC<Props> = ({ children }) => {
    const [toastInfo, setToastInfo] = useState<toastInfo>(initToastInfo);

    return (
        <TosatContext.Provider value={toastInfo}>
            <SetToastContext.Provider value={setToastInfo}>
                {children}
            </SetToastContext.Provider>
        </TosatContext.Provider>
    );
};

export default AppContext;
```
```javascript:index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import {RouterProvider, createBrowserRouter} from 'react-router-dom';
import './index.css';
import AppContext from './context/AppContext';

// react-router-dom を使ったルーティングに関する設定
const router = createBrowserRouter([
    // 本筋に関係ないため省略
]);

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <React.StrictMode>
    <AppContext>
      <RouterProvider router={router} />
    </AppContext>
  </React.StrictMode>
);
```
# 各コンポーネントの関係性

ソースコードを一度に記載したことで情報量が多いため、少し関係性をまとめたいと思います。
各コンポーネントとそれに付随するファイルの関係性については次のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3031330/81bbe655-07c9-18ea-403d-50d48267d957.png)


# 実装解説

今回実装したトーストコンポーネントについて簡単に解説したいと思います。

## トーストコンポーネントについて

トーストコンポーネントを実装するにあたって、一番大切な要素は下からひょっこり出てくるアニメーションですね。
アニメーションについては CSS で実現しています。
具体的には @keyframes にて一連のアニメーション流れの各フレームにおける要素の位置を定義しています。
そのため、初期位置は 100vh （画面外）に、アニメーション全体の0~15%の間にて 90vh の位置に移動、85%~100%の間にて初期位置に移動するように設定しています。
この設定によって、フェードイン・アウトを実現しています。

後は、css セレクターによってアニメーションの期間などを設定します。
トーストに対応するdiv要素にこのcss セレクターを適応させることでアニメーションが発火します。

## トーストコンポーネントのアプリ適応

作成したトーストコンポーネントをどの画面でも使用したかったため、状態をアプリ全体に持たせる必要がありました。
そのため、ルーターコンポーネントの上位階層にuseContextフックを使用したコンポーネントを作成し（AppContext.tsx）、useStateを使用したトーストに関する状態情報とそのセッター関数を持たせることで、各画面において、トーストの状態情報とセッター関数を参照することができるようになりました。

# さいごに

Webアプリケーションにおいて、トーストはあまり見ないためUIのため、
ユーザのアクションに対するメッセージ表示となると別の方法が良いのかも知れません、、、
実装に関してもっとこうしたほうがいいのではないかなどなにか知見があれば、コメントいただければと思います。

# 参考

[1] https://developer.android.com/guide/topics/ui/notifiers/toasts?hl=ja
[2] https://www.wantedly.com/users/26190108/post_articles/345447