Snippet

主に技術系の単発ネタを書き留めたメモ。同じところで悩む誰かの役に立てれば。

React Navigation

2021/04/22

インストール

yarn でパッケージをインストールする;

yarn add @react-navigation/native @react-navigation/stack @react-navigation/bottom-tabs

Expo に関連パッケージを認識させるために、expo install を実行;

expo install react-native-gesture-handler react-native-reanimated react-native-screens react-native-safe-area-context @react-native-community/masked-view

Stack ナビゲーションの作成

export type RootStackParamList = {
  Main: undefined;
  SignUp: undefined;
};

routes.ts を作成し、ルーティング情報を型として定義します。undefined になっているところは、ルーティングする際に id や order などの情報を追加付与する際に、その型情報を記載する箇所です。(引数がなければ undefined で構いません)

import { RootStackParamList } from './routes';

import * as React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';

import { SignUp } from './SignUp';
import { Main } from './Main';

const Stack = createStackNavigator<RootStackParamList>();

export const Navigation = () => {
  return (
    <NavigationContainer>
      <Stack.Navigator initialRouteName="Main">
        <Stack.Screen name="Main" component={Main} options={{ title: 'メイン画面' }} />
        <Stack.Screen name="SignUp" component={SignUp} options={{ title: 'メンバー登録' }} />
      </Stack.Navigator>
    </NavigationContainer>
  );
};
  • name がルーティング名になる。大文字小文字を無視する。大文字含む名称が推奨されている
  • createStackNaviagtor に型を付ける。各コンポーネントの引数をまとめた型を作ると、name に型制約がつく
  • options で表示名などを指定できる

各 Screen の作成

import React from 'react';
import { StackNavigatStackScreenPropsionProp } from '@react-navigation/stack';

import { RootStackParamList } from '../Navigation';

export type MainProps = StackScreenProps<RootStackParamList, 'Main'>;

export const Main = ({ navigation }: MainProps) => (
  <Box w="100%" maxW="100%" h="100%" display="flex" justifyContent="center" alignItems="center">
    <VStack spacing={4} h="100%" px={4} py={8} bgColor={palette.white}>
      <Typography color={palette.brown} fontSize="xx-large">
        メイン画面
      </Typography>

      <Center>
        <Button label="メンバー登録" onPress={() => navigation.navigate('SignUp')} />
      </Center>
    </VStack>
  </Box>
);
  • RootStackParamList をもとに MainNavigationProps を作る
  • Main コンポーネントに react-navigation から navigation が渡されるので、それをもとにスタック制御をする
  • navigation.navigate(name) で対象の画面へ遷移できる

引数の授受(/users/:id のようなもの)

export type RootStackParamList = {
  Main: undefined;
  SignUp: undefined;
  UserDetail: { id: string }; // 受け渡ししたい引数を定義する
};

引数として渡したいパラメータの型定義を付与する。

<Button label="ユーザ画面" onPress={() => navigation.navigate('UserDetail', { id: '42' })} />

任意の画面で navigate を呼び出すと、引数に型がついており、正しい引数だけを渡せるようになっている。

export type UserDetailProps = StackScreenProps<RootStackParamList, 'UserDetail'>;

export const UserDetail = ({ navigation, route }: UserDetailProps) => (
  <Box w="100%" maxW="100%" h="100%" display="flex" justifyContent="center" alignItems="center">
    <VStack spacing={4} h="100%" px={4} py={8} bgColor={palette.white}>
      <VStack spacing={4}>
        <Typography color={palette.brown} fontSize="xx-large">
          {`ユーザ画面 id=${route.params.id}`}
        </Typography>
      </VStack>

      <Center>
        <Button label="メイン画面へ戻る" onPress={() => navigation.navigate('Main')} />
      </Center>
    </VStack>
  </Box>
);
  • route.params に型がついた props が渡されるので、これを利用すれば OK。
  • setParams でパラメータの更新が可能
  • initialParams を指定しておくことが可能
  • 親画面の params を書き換えることも可能(e.g.ユーザ作成してユーザが増えた)。この場合は navigate か goBack に引数を指定すれば良い。{merge: true}すると合成してくれる模様。

ヘッダのスタイリング

export const Navigation = () => {
  const theme = useContext<ThemeColors>(ThemeContext);
  const screenOptions: StackNavigationOptions = {
    headerStyle: { backgroundColor: theme.secondary },
    headerTintColor: palette.white,
    headerTitleStyle: { fontWeight: 'bold' },
  };

  return (
    <NavigationContainer>
      <Stack.Navigator initialRouteName="Main" screenOptions={screenOptions}>
        <Stack.Screen name="Main" component={Main} options={{ title: 'メイン画面' }} />
      </Stack.Navigator>
    </NavigationContainer>
  );
};
  • Navigator に screenOptions を指定すると、ヘッダスタイルを変更できる
  • スタイルではなくコンポーネントを指定することも可能
  • 個別の Screen に対して screenOptions を指定することもできる

タブナビゲーションの追加

import { RouteProp } from '@react-navigation/core';
import {
  BottomTabBarOptions,
  BottomTabNavigationOptions,
  createBottomTabNavigator,
} from '@react-navigation/bottom-tabs';
import Feather from 'react-native-vector-icons/Feather';

// main tab に属する screen 一覧
export type MainTabParamList = {
  Home: undefined;
  UserDetail: { id: string };
};

// route.name と iconName をマッチさせる辞書
const tabIconNames: Record<keyof MainTabParamList, string> = {
  Home: 'home',
  UserDetail: 'user',
};

// route.name と color, size を使って Feather アイコンをレンダリング
const getScreenOptions = (route: RouteProp<MainTabParamList, keyof MainTabParamList>) =>
  ({
    tabBarIcon: ({ color, size }) => (
      <Feather name={tabIconNames[route.name]} size={size} color={color} />
    ),
  } as BottomTabNavigationOptions);

// 下タブのナビゲーションを作成
const Tab = createBottomTabNavigator<MainTabParamList>();

export const Main = () => {
  const theme = useContext<ThemeColors>(ThemeContext);

  // タブのアクティブ・インアクティブカラーを設定
  const tabBarOptions: BottomTabBarOptions = {
    activeTintColor: theme.secondary,
    inactiveTintColor: palette.gray,
  };

  return (
    <Tab.Navigator
      screenOptions={({ route }) => getScreenOptions(route)}
      tabBarOptions={tabBarOptions}
    >
      <Tab.Screen name="Home" component={Home} />
      <Tab.Screen name="UserDetail" component={UserDetail} initialParams={{ id: '42' }} />
    </Tab.Navigator>
  );
};
  • createBottomTabNavigator で下タブのナビゲーションを作成できる
  • tabBarOptions でアクティブカラーなどを設定できる
  • タブアイコンやアクティブ状態などは、専用のレンダリング関数を作って変更する
    • Tab.navigator#screenOptions へ route を引数とした関数を登録できる
    • 受け取った route と、tabBarIcon から受け取れる color, size などを使い、アイコンのレンダリングを行う

ナビゲーションのネスト

export const Navigation = () => (
  <NavigationContainer>
    <Stack.Navigator initialRouteName="Main" screenOptions={{ headerShown: false }}>
      <Stack.Screen name="Modal" component={Modal} />
      <Stack.Screen name="Main" component={Main} />
      <Stack.Screen name="Auth" component={Auth} />
    </Stack.Navigator>
  </NavigationContainer>
);
  • Root: StackNav
    • Modal
    • Auth: StackNav
      • SignIn
      • SignUp
    • Main: BottomTabNav
      • Home
      • UserDetail

こんな感じでネストしてナビゲーションを作成できる。

  • 子ナビゲーションごとにヒストリが作成される
  • 子ナビゲーション間で情報の共有はない
  • 子ナビゲーションでハンドルできなかったナビは、親にバブリングされる
  • 親ナビゲーションの情報は、子ナビゲーションには伝達されない
  • 親子それぞれにナビゲーション UI があった場合は、重複してレンダリングされる

ネストしたスクリーンへ props を渡したい場合は、screen, params を指定すれば良い。さらにネストした指示もできる。

navigation.navigate('Root', {
  screen: 'Settings',
  params: { user: 'jane' },
});

references

React Native のフォーム処理(react-hook-form + yup)

2021/04/08

react-hook-form の特徴

パフォーマンスにフォーカスしており、redux-form や formik より高速に動作することを謳っている。その方法論として「非同期コントロール」であることを打ち出している(redux-form や formik は同期型)。

値の保持については DOM 側にまかせ、その変更を addEventListener を介して検知するという手法をとっている模様。同期型だと値の変更ごとに書き換え(=再レンダリング)が必要だったのに対し、非同期型の場合は値の変更そのものは DOM 処理のため、React による再レンダリングは発生しない。これによりマウント後の高速性を担保している。

インストール

yarn でパッケージをインストールする;

yarn add react-hook-form yup @hookform/resolvers

ベースになるテキスト入力コンポーネントの作成

import React, { ReactNode } from 'react';
import { TextInput as RnTextInput } from 'react-native';

import { Typography } from '../../atoms/Typography';

export type TextInputProps = TextInputStyledProps & {
  label?: ReactNode;
  value?: any;
  onBlur?: () => void;
  onChangeText?: (text: string) => void;
};

export const TextInput = ({ label, ...props }: TextInputProps) => {
  return (
    <>
      {label && (
        <Typography w="100%" textAlign="left">
          {label}
        </Typography>
      )}
      <RnTextInput {...props} />
    </>
  );
};

value onBlur onChangeText などが RHF から渡されるので、それを react-native の TextInput に引き渡している。ほかに styled-components によるスタイリングも行っているが、解説の範囲外につき省略。

ベースコンポーネントを RHF の Controller でラップ

import React from 'react';
import { FieldValues, Controller, DeepMap, FieldError } from 'react-hook-form';

import { TextInput, TextInputProps } from './TextInput';
import { Typography } from '../../atoms/Typography';

import { RhfProps } from '../type';
import { palette } from '../../styles/color';

export type RhfTextInputProps<T extends FieldValues> = TextInputProps & RhfProps<T>;

export const RhfTextInput = <T extends FieldValues>({
  control,
  name,
  rules,
  defaultValue,
  ...styles
}: RhfTextInputProps<T>) => {
  return (
    <Controller
      control={control}
      name={name}
      rules={rules}
      defaultValue={defaultValue}
      render={({ field: { onChange, onBlur, value }, formState: { errors } }) => (
        <>
          <TextInput
            {...styles}
            value={value}
            onBlur={onBlur}
            onChangeText={(value) => onChange(value)}
          />
          {errors[name] && (
            <Typography w="100%" textAlign="left" color={palette.red}>
              {(errors[name] as DeepMap<FieldValues, FieldError>)?.message}
            </Typography>
          )}
        </>
      )}
    />
  );
};

RHF では React の ref を使って DOM を監視しているが、React Native の場合はこの手法が使えない。かわりに Controller というラッパーコンポーネントを提供しており、これが値の同期を行ってくれる。RN で RHF を使う場合は、基本的に Controller で囲うことが前提になりそうだ。

Controller は render props によって対象のフィールドへ付与してほしい value onBlur onChangeText などを渡してくるので、これを先程作ったベースコンポーネントに渡す。

バリデーションエラーは formState.errors[name].message に入っているので、これが存在する場合はエラー内容を表示するよう、コンポーネントの振る舞いを追加する。

Form を作成

import React from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';

import { VStack } from './atoms/VStack';
import { Button } from './atoms/Button';
import { RhfTextInput } from './forms/TextInput/ReactNative';

type FormData = {
  username: string;
  password: string;
};

const schema = yup.object().shape({
  username: yup.string().required(),
  password: yup.string().required(),
});

export const FormSample = () => {
  const { control, handleSubmit } = useForm<FormData>({ resolver: yupResolver(schema) });
  const onSubmit = (data: FormData) => console.log(data);

  return (
    <VStack spacing={4} w="100%">
      <RhfTextInput control={control} name="username" label="ユーザ名" />
      <RhfTextInput control={control} name="password" label="パスワード" />

      <Button label="送信" onPress={handleSubmit(onSubmit)} w="100%" h="64px" />
    </VStack>
  );
};

react-hook-form の名前が示すとおり、すべて hooks によるデータ管理がなされる。フォーム処理の基本となる情報は useForm hook を通して取得できる。

バリデーションは yup スキーマをサポートしており、@hookform/resolvers/yupyupResolver がバリデーションに関する解決を行ってくれる。これを useForm<FormData>({ resolver: yupResolver(schema)}) という形で指定すれば、RHF が適宜バリデーション処理を行ってくれる。

useForm で得られた control は各コンポーネントに渡し、handleSubmit は送信処理関数へ渡す。

references

React Native で styled-components を使う

2021/04/06

インストール

yarn でパッケージをインストールする。types が別パッケージ、かつ React Native のものはさらに別パッケージになっているので、すべてインストールする。

yarn add styled-components
yarn add -D @types/styled-components @types/styled-components-react-native

React Native のコンポーネントをスタイリングする

styled を import し、それを経由してスタイリングしたいコンポーネントを指定する。スタイリングには CSS 構文が利用できる。

import React, { ReactNode } from 'react';
import styled from 'styled-components/native';

export const VStackStyled = styled.View`
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  align-items: center;
`;

export type VStackProps = {
  children?: ReactNode;
};

export const VStack = ({ children }: VStackProps) => <VStackStyled children={children} />;

変数を使う

props を args とした関数を使うと、props を取り出すことができる。この値を加工して CSS として有効な値に変換する

import React, { ReactNode } from 'react';
import styled from 'styled-components/native';

export type HStackProps = {
  w?: number | string;
  maxW?: number | string;
};

export const HStack = styled.View`
  display: flex;
  flex-direction: row;
  justify-content: space-between;
  align-items: stretch;

  width: ${({ w }: HStackProps) => w ?? 'auto'};
  max-width: ${({ maxW }: HStackProps) => maxW ?? 'auto'};
`;

テーマ

ThemeProvider を使ってテーマ変数を供給する。

export const themeColors: ThemeColors = {
  primary: colorPalettes.indigo,
  secondary: colorPalettes.brown,
};

const App = () => <ThemeProvider theme={themeColors}>...</ThemeProvider>;

利用する側は useContext を使って取り出すと良い。

import React, { useContext } from 'react';
import { ThemeContext } from 'styled-components';
import styled from 'styled-components/native';

import { ThemeColors } from '../../styles/color';

type ButtonStyledProps = {
  theme: ThemeColors;
};

const ButtonContainer = styled.TouchableOpacity`
  background-color: ${({ theme }: ButtonStyledProps) => theme.primary};
`;

export const Button = ({ onPress, title }: ButtonProps) => {
  const theme = useContext<ThemeColors>(ThemeContext);

  return (
    <ButtonContainer activeOpacity={0.75} onPress={onPress} theme={theme}>
      <ButtonText fontSize="large">{title}</ButtonText>
    </ButtonContainer>
  );
};

注意点

  • styledstyled-components/native から import するが、ほかのものは styled-components から import することが多いため、どちらを参照すべきかに注意を払う
  • React Native 版の styled-components では隣接セレクタは使用できない。ほか CSS の機能を使う場合には、Web 版で実現できたことが React Native 版では実現できない場合があるかもしれないので、公式ドキュメントを随時確認する

references

React Native と Web/Android/iOS Simulator をセットアップする

2021/04/05

手順は下記の通り;

  • expo-cli をインストール
  • サンプルプロジェクトを自動生成
  • web で動作確認
  • iOS(Xcode/Simulator) で動作確認
  • Android(Android Studio/AVD) で動作確認

expo-cli をインストール

yarn global add expo-cli で Expo CLI をインストールできる

$ yarn global add expo-cli
success Installed "expo-cli@4.3.4" with binaries:
      - expo
      - expo-cli
✨  Done in 26.43s.

サンプルプロジェクト (AwesomeProject) を作成

expo init でプロジェクトを作成でき、yarn start で開発環境を起動できる。

$ expo init AwesomeProject
✔ Choose a template: › blank (TypeScript)    same as blank but with TypeScript configuration
✔ Downloaded and extracted project files.
🧶 Using Yarn to install packages. Pass --npm to use npm instead.
✔ Installed JavaScript dependencies.

✅ Your project is ready!

web ブラウザをターゲットとして動作確認

yarn start した CLI 上で、アプリを実行するターゲットを指定できる。シミュレータがインストールされていない環境でも、web(ブラウザ)をターゲットとして起動することができる。

$ yarn start
 › Press w │ open web

iOS (Xcode) のセットアップと動作確認

  • ‎「Xcode」を Mac App Store で のリンクをクリックして、AppStore を開き、そこから Xcode をインストール
  • $ sudo xcode-select -s /Applications/Xcode.app を実行しておく
  • Simulator を起動し、iPhone のシミュレータを表示
  • expo start したターミナルで i を入力すると、Simulator との自動連携が始まり、最終的にアプリが起動する

Android (Android Studio) のセットアップと動作確認

  • Download Android Studio and SDK tools のリンクをクリックし、Android Studio をインストール
  • ウィザードに従って初期設定を行っておく
  • AVD Manager を開き、Virtual Device を 1 つ起動しておく(Pixel など)
  • expo start したターミナルで a を入力すると、AVD との自動連携が始まり、最終的にアプリが起動する

web/iOS/Android で起動した例

/contents/snippet/2021-04-05-setup-expo-react-native/simulators.png

references

nvmからnodenvに移行する

2021/04/04

手順は下記の通り;

  • nvm と nodejs をアンインストール
  • bash_profile などを編集
  • nodenv をインストール
  • nodejs をインストール

nvm list でインストール済みの nodejs を確認し、nvm uninstall を行う。アクティブな nodejs を削除する場合は、nvm deactivate を実行してから nvm uninstall を実行する必要がある。

$ nvm list
       v10.22.0
->      v14.6.0
         system

$ nvm uninstall 10
Uninstalled node v10.22.0

$ nvm deactivate
/Users/mkubara/.nvm/*/bin removed from ${PATH}

$ nvm uninstall 14
Uninstalled node v14.6.0

$ nvm list
->       system

nvm と nodejs のデータを削除

$ echo $NVM_DIR
/Users/mkubara/.nvm

$ rm -rf $NVM_DIR
$ sudo rm -rf ~/.npm

bash_profile, bashrc などを編集し、nvm 関係の情報を削除

$ vi ~/.bash_profile
$ source ~/.bash_profile

nodenv を clone し、bash を更新

$ git clone https://github.com/nodenv/nodenv.git ~/.nodenv

$ echo 'export PATH="$HOME/.nodenv/bin:$PATH"' >> ~/.bash_profile
$ echo 'eval "$(nodenv init -)"' >> ~/.bash_profile
$ source ~/.bash_profile

シェルを再起動したあと、プラグインをインストール

$ git clone https://github.com/nodenv/node-build.git ~/.nodenv/plugins/node-build
$ git clone https://github.com/nodenv/nodenv-update.git "$(nodenv root)"/plugins/nodenv-update

シェルを再起動したあと、nodejs をインストール

$ nodenv update
$ nodenv install 14.16.0
$ nodenv rehash
$ nodenv global 14.16.0

$ node -v
v14.16.0

references

Next.jsサイトにsitemapを実装する

2021/01/18

/pages/sitemap.xml.tsx
import React from 'react';
import { GetServerSideProps } from 'next';

import { SITE_URL } from '../utils/env';
import { mergeUrlAndSlug, UrlTable } from '../utils/path/url';
import { getAvailableSlugs } from '../utils/article/fs.server';

const FIXED_URLS = [UrlTable.root, UrlTable.about, UrlTable.blog];
const siteUrl = SITE_URL.slice(0, -1);

const genUrl = (url: string) => `<url><loc>${siteUrl + url}</loc></url>`;
const genFromSlugs = async (urlBase: string, urlPost: string) => {
  const slugs = await getAvailableSlugs(urlBase);
  return slugs.map((slug) => genUrl(mergeUrlAndSlug(slug, urlPost)));
};

export const getServerSideProps: GetServerSideProps = async ({ res }) => {
  const smFixed = FIXED_URLS.map((url) => genUrl(url));
  const smBlogPosts = await genFromSlugs(UrlTable.blog, UrlTable.blogPosts);

  const sitemap = [...smFixed, ...smBlogPosts].join('');

  if (res) {
    res.setHeader('Content-Type', 'text/xml');
    res.write(`<?xml version="1.0" encoding="UTF-8"?>
    <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${sitemap}
    </urlset>`);
    res.end();
  }

  return {
    props: {},
  };
};

const Sitemap: React.FC = () => null;

export default Sitemap;

sitemap も pages でレンダリングする。コツはファイル名を /pages/sitemap.xml.tsx として XML を返すことを明示することと、SSR(getServerSideProps) の res.setHeadertext/xml として返すこと。

  • SSG のページは、固定の URL テーブルを作成して、それを返す。
  • slug などの dynamic route を使っているページは、getStaticPaths で利用している関数をそのまま sitemap.xml.tsx でも利用すればよい

references

markdownページでnext/imageを使う

2021/01/15

import React from 'react';
import Image from 'next/image';

type NextImageProps = {
  src: string;
  alt?: string;
};

export const NextImage: React.FC<NextImageProps> = ({ src, alt, ...props }) => (
  <div
    style={{
      display: 'flex',
      position: 'relative',
      justifyContent: 'center',
      alignItems: 'center',
      width: '100%',
      height: '16em',
      marginBottom: '1.75em',
      backgroundColor: '#f7fafc',
    }}
  >
    <Image {...props} src={src} alt={alt || src} layout="fill" objectFit="contain" />
  </div>
);

markdown で利用する画像サイズは予測しづらいので、next/image の layout="fill" を使い、動的にリクエストを変更してもらう。親コンテナ(のサイズ)が必要になるので、div で wrap しておき、画像自身は objectFit="contain" で枠に収まるように設定した。

import remarkUnwrapImages from 'remark-unwrap-images';

const getDefaultMdxOptions = () => ({
  // ...
  remarkPlugins: [
    // ...
    remarkUnwrapImages,
  ],
});

markdown のデフォルトだと、image をpで囲ってしまい、p -> divの HTML 違反となってしまうため、remark-unwrap-images で p を外す。next-mdx-remote の mdxOptions に remarkUnwrapImages を追加することで対応できる。

references

markdownにカスタムブロックを追加する

2021/01/12

yarn add remark-custom-blocks

remark-custom-blocks を使うと、markdown にカスタムブロックを追加できる。

import remarkCustomBlocks from 'remark-custom-blocks';

const getDefaultMdxOptions = () => ({
  // ...
  remarkPlugins: [
    [
      remarkCustomBlocks,
      {
        exercise: {
          classes: 'exercise',
          title: 'required',
        },
      },
    ],
  ],
});

next-mdx-remote の mdxOptions に remarkCustomBlocks を追加し、オプションを設定する。classes でブロックにあてる CSS クラス名を、title でタイトル部分が必須かどうかを設定できる。

/* 
 * Custom Blocks
 */
.exercise,
.practice {
  border: 1px solid #e2e8f0;
  border-radius: 0.5em;
  margin-bottom: 1.75em;

  .custom-block-heading {
    padding: 0.5em 1em;
    border-radius: 0.5em 0.5em 0 0;
    font-size: 1em;
    font-weight: bold;
  }

  .custom-block-body {
    border-radius: 0 0 0.5em 0.5em;
    padding: 1em;
    font-size: 1em;

    & ol,
    & ul,
    & p {
      margin-bottom: 0;
    }
  }
}

.exercise {
  border-color: #81e6d9;

  .custom-block-heading {
    background-color: #e6fffa;
  }
}

CSS クラス名に対応するスタイルを記述する。今回はブロック全体を角丸矩形で囲い、タイトル部分に背景色を当てた。

/contents/snippet/2021-01-12-remark-custom-blocks/remark-custom-blocks.png
[[exercise | 練習問題 : 三角形の面積を求める]]
| 1. 整数を 1 つ受け取り、その値を 2 倍した値を表示してください
| 2. 整数を 2 つ受け取り、その値の積を表示してください
| 3. 小数を 2 つ受け取り、三角形の面積を求めるプログラムを記述してください
| 4. 問題(3)で、小数点以下は 1 桁で表示するようにしてください

[[name | title]] が開始部分になり、以下 | が続く限りブロックとみなす。| の内部で別の markdown 記法を使っても良い

references

ボタンを押すと滑らかにスクロールしてコンテンツを表示

2021/01/08

export const SomePage = () => {
  const refContents = useRef<HTMLDivElement>();

  const scrollToContents = useCallback(() => {
    refContents.current.scrollIntoView({
      behavior: 'smooth',
      block: 'start',
    });
  }, [refContents]);

  return (
    <VStack w="100%" minH="calc(100vh - 64px)">
      <Center h={16} pb={16}>
        <IconButton
          aria-label="Scroll to Contents section"
          icon={<FaArrowDown />}
          fontSize="2em"
          onClick={scrollToContents}
        />
      </Center>

      {/* 他の内容省略 */}

      <Box backgroundColor="gray.50" minH="16em" px={[0, 8, 16, 24]} ref={refContents}>
        {/* コンテンツ */}
      </Box>
    </VStack>
  );
};

scrollIntoView を使うと、対象の DOM へスクロールできる。

  • behavior: 'smooth' とすると、スムーススクロールでジャンプしてくれる
  • block で要素の先頭・中央・末尾のいずれへジャンプするかを指定できる
  • DOM 要素の参照は ref で取得し useRef を通して保存する。取得した参照は ref.current からアクセスできる
  • IconButton を利用すると、アイコンをボタン要素として利用できる。SVG アイコンであれば fontSize で大きさを指定できる
  • アイコンを利用したリンク要素やボタン要素には、aria-label をつけること。アイコン単独だと可読な文字列がなく、スクリーンリーダーなどに対応できないため。これに限らず a11y は意識していきたい

references

カルーセルでカードを表示する

2021/01/07

import React from 'react';
import { Box } from '@chakra-ui/react';
import Slick, { Settings } from 'react-slick';

import 'slick-carousel/slick/slick.css';
import 'slick-carousel/slick/slick-theme.css';

const settings: Settings = {
  dots: true,
  infinite: true,
  centerMode: true,
  slidesToShow: 3,
  autoplay: true,
  speed: 500,
  cssEase: 'ease-out',
  responsive: [
    {
      breakpoint: 992,
      settings: {
        slidesToShow: 2,
      },
    },
    {
      breakpoint: 640,
      settings: {
        slidesToShow: 1,
      },
    },
  ],
};

export const SlickArticles: React.FC = ({ articles }) => (
  <Slick {...settings}>
    {articles.map((a) => (
      <Box key={a.slug} p={[2, 4]}>
        <ArticleCard article={a} />
      </Box>
    ))}
  </Slick>
);

react-slick ライブラリを使えば達成できる。

  • responsive オプションで、タブレットビューやスマホビューにも対応できる。max-width タイプの指定になるので、Chakra で慣れている場合は逆指定になることに注意
  • autoplayinfinite で自動スクロール
  • centerMode を指定すると、左右にカードの一部が表示されるため、無限にあるように見えやすくなる。カードの枚数は slidesToShow で指定
  • エレメントに padding はつかないため、自分でつけてから渡す(ここでは Box でラップしている)
  • エレメントの高さにあった height に自動的になるが、dots ぶんの高さは確保されないので、margin-bottom などで調整する

references

SNSシェアボタンを上スクロール時のみフェードインさせる

2021/01/06

import React, { useState } from 'react';
import { VStack, SlideFade, Box, Icon, Placement, Tooltip } from '@chakra-ui/react';
import { useScrollPosition } from '@n8tb1t/use-scroll-position';
import { TwitterShareButton } from 'react-share';
import { FaTwitter } from 'react-icons/fa';

import { SITE_URL, TWITTER_ID } from '../../../utils/env';

type ShareButtonsLeftFixedProps = {
  urlBlog: string;
  title: string;
};

export const ShareButtonsLeftFixed: React.FC<ShareButtonsLeftFixedProps> = ({ title, urlBlog }) => {
  const [showShareButtons, setShowShareButtons] = useState(true);

  useScrollPosition(({ prevPos, currPos }) => {
    const visible = currPos.y > prevPos.y;
    setShowShareButtons(visible);
  }, []);

  const url = new URL(urlBlog, SITE_URL).toString();

  return (
    <Box
      position="fixed"
      left="calc(50vw - 28em)"
      top="7em"
      visibility={['hidden', 'hidden', 'hidden', 'visible']}
    >
      <SlideFade in={showShareButtons} offsetX="-1em" offsetY={0}>
        <VStack spacing={4} p={4} backgroundColor="gray.50" borderRadius={8}>
          <TwitterShareButton url={url} title={title} via={TWITTER_ID}>
            <Tooltip
              label="Twitterでシェア"
              shouldWrapChildren
              hasArrow
              placement={tooltipPlacement}
            >
              <Icon as={FaTwitter} boxSize={6} fill="gray.400" _hover={{ fill: 'teal.500' }} />
            </Tooltip>
          </TwitterShareButton>

          {/* 以下のボタンは省略 */}
        </VStack>
      </SlideFade>
    </Box>
  );
};
  • 上スクロールを検知するには use-scroll-position を利用する
  • スライドアニメーションのために Chakra-UI の SlideFade を利用する
  • タップすると何が起こるのかを提示するために Chakra-UI の Tooltip を利用する

references

SNSシェアボタンの作成

2021/01/05

import React from 'react';
import { Icon, Placement, Tooltip } from '@chakra-ui/react';
import {
  FacebookShareButton,
  HatenaShareButton,
  LineShareButton,
  PocketShareButton,
  TwitterShareButton,
} from 'react-share';
import { FaFacebook, FaGetPocket, FaLine, FaTwitter } from 'react-icons/fa';
import { SiHatenabookmark } from 'react-icons/si';

import { SITE_URL, TWITTER_ID } from '../../../utils/env';

type ShareButtonsProps = {
  urlBlog: string;
  title: string;
  tooltipPlacement: Placement;
};

export const ShareButtons: React.FC<ShareButtonsProps> = ({ urlBlog, title, tooltipPlacement }) => {
  const url = new URL(urlBlog, SITE_URL).toString();

  return (
    <>
      <TwitterShareButton url={url} title={title} via={TWITTER_ID}>
        <Icon as={FaTwitter} boxSize={6} fill="gray.400" _hover={{ fill: 'teal.500' }} />
      </TwitterShareButton>
      <FacebookShareButton url={url}>
        <Icon as={FaFacebook} boxSize={6} fill="gray.400" _hover={{ fill: 'teal.500' }} />
      </FacebookShareButton>
      <LineShareButton title={title} url={url}>
        <Icon as={FaLine} boxSize={6} fill="gray.400" _hover={{ fill: 'teal.500' }} />
      </LineShareButton>
      <PocketShareButton title={title} url={url}>
        <Icon as={FaGetPocket} boxSize={6} fill="gray.400" _hover={{ fill: 'teal.500' }} />
      </PocketShareButton>
      <HatenaShareButton title={title} url={url}>
        <Icon as={SiHatenabookmark} boxSize={6} fill="gray.400" _hover={{ fill: 'teal.500' }} />
      </HatenaShareButton>
    </>
  );
};

react-share ライブラリを使用する。外部スクリプトの読み込みや、テンプレート文字列の流し込みなど、必要な操作はすべて対応してくれる。

各業者のアイコンについては、今回はトーンを揃えたかったので react-icons の FontAwesome や Simple を利用した。react-share 自身にも各種アイコンは入っているので、それを利用しても OK。

import { FacebookIcon } from 'react-share'; // react-share の組み込み
import { FaFacebook } from 'react-icons/fa'; // react-icons を使う場合

<Icon as={FaTwitter} boxSize={6} fill="gray.400" _hover={{ fill: 'teal.500' }} />;

references

上スクロールしたときだけ表示されるヘッダ

2021/01/04

import { useScrollPosition } from '@n8tb1t/use-scroll-position';

export const Header: React.FC = ({ children }) => {
  const [showMenu, setShowMenu] = useState(true);

  useScrollPosition(({ prevPos, currPos }) => {
    const visible = currPos.y > prevPos.y;
    setShowMenu(visible);
  }, []);

  return (
    <Center
      as="nav"
      visibility={showMenu ? 'visible' : 'hidden'}
      transition={`all 200ms ${showMenu ? 'ease-in' : 'ease-out'}`}
      transform={showMenu ? 'none' : 'translate(0, -100%)'}
    >
      {children}
    </Center>
  );
};

useScrollPosition Hooks を利用して、スクロールイベントをフックする。前回と今回の Y 座標を比較して、今回のほうが大きい(上にスクロールした)場合は、ヘッダを表示する。

hooks の引数無指定の場合は document.body の boundingClientRect が参照される。下にスクロールすると、body の top は上方向に移動する= body の Y 座標はどんどんマイナス値になっていく

表示トランジションには、Chakra UI の Collapse などを利用しても良い。ただページ遷移の際にもアニメーションしてしまって、少し違和感を覚えたので、上記の例ではトランジションを自筆している。

references

mdxファイルのリンクにtarget=_blankなどを入れる

2021/01/03

import hydrate from 'next-mdx-remote/hydrate';

export const LinkWithTargetBlank = (props) => {
  const { href, ...rest } = props;
  if (href.startsWith('http'))
    return <a href={href} target="_blank" rel="noopener noreferrer" {...rest} />;

  return <Link to={href} {...rest} />;
};

hydrate(content, {
  components: {
    // ...
    a: LinkWithTargetBlank,
  },
});
  • href が外部リンクの場合は、a タグで展開し、target="_blank"rel="noopener noreferrer" を付与する
  • href が内部リンクの場合は、next/link で展開する

Chakra UI の Link が使える場合は、target, ref のかわりに isExternal を付与するだけで OK。

references

Next.jsにGoogleAnalyticsを入れる

2021/01/02

.env.production.local
NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=YOUR_TRACKING_ID
utils/analytics/gtag.ts
export const GA_TRACKING_ID = process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID;

export const pageview = (url: string) => {
  window.gtag('config', GA_TRACKING_ID, {
    page_path: url,
  });
};

export const event = ({ action, category, label, value }) => {
  if (!GA_TRACKING_ID) return;

  window.gtag('event', action, {
    event_category: category,
    event_label: JSON.stringify(label),
    value: value,
  });
};
_document.tsx
import { GA_TRACKING_ID } from '../utils/analytics/gtag';

// Update <Head /> as below
<Head>
  {GA_TRACKING_ID && (
    <>
      <script
        async
        src={`https://www.googletagmanager.com/gtag/js?id=${GA_TRACKING_ID}`}
      ></script>
      <script
        dangerouslySetInnerHTML={{
          __html: `
      window.dataLayer = window.dataLayer || [];
      function gtag(){dataLayer.push(arguments);}
      gtag('js', new Date());

      gtag('config', '${GA_TRACKING_ID}');`,
        }}
      ></script>
    </>
  )}
</Head>
_app.tsx
import React, { useEffect } from 'react';
import { useRouter } from 'next/router';

import * as gtag from '../utils/analytics/gtag';

const App: React.FC<AppProps> = ({ Component, pageProps }) => {
  const router = useRouter();
  useEffect(() => {
    if (!gtag.GA_TRACKING_ID) return;

    const handleRouteChange = (url: string) => {
      gtag.pageview(url);
    };

    router.events.on('routeChangeComplete', handleRouteChange);
    return () => {
      router.events.off('routeChangeComplete', handleRouteChange);
    };
  }, [router.events]);

  // ...
}
next-env.d.ts
interface Window {
  gtag(type: 'config', googleAnalyticsId: string, { page_path: string });
  gtag(
    type: 'event',
    eventAction: string,
    fieldObject: {
      event_label: string;
      event_category: string;
      value?: string;
    },
  );
}

references

Array.map で async/await を使う

2020/12/30

子要素を親要素からはみ出して配置するCSS

2020/12/30

.full {
  margin: 0 calc(50% - 50vw);
  padding: 0 calc(50vw - 50%);
  width: 100vw;
}

margin で画面端に寄せて、padding で中央に戻す。

発生した現象

例えば記事ページにおいて横幅を 40em で固定してあるときに、記事内部の画像やコードなどを全幅で表示したいような場合。position を指定する方法だと、height がわかってないとダメなので使えない。

具体的な手法

<div class="content">
  <div class="inner">
    <div class="full">ここを画面幅いっぱいにしたい</div>
  </div>
</div>
.content {
  overflow: hidden;
}

.inner {
  width: 40em;
  max-width: 100%;
  margin: 0 auto;
}

.full {
  margin: 0 calc(50% - 50vw);
  padding: 0 calc(50vw - 50%);
  width: 100vw;
}

position: static のままで問題なく機能する。

references

useRefの使いどき

2020/12/29

下記の 2 パターンで利用する;

  1. DOM ノードの参照を持つときに使う(e.g. <input ref={ref} />
  2. re-render を走らせたくない変数に使う(e.g. タイマー ID)

DOM ノードの参照を持つときに使う

ja.reactjs.org/docs/hooks-reference.html#useref
function TextInputWithFocusButton() {
  const inputEl = useRef(null);
  const onButtonClick = () => {
    // `current` points to the mounted text input element
    inputEl.current.focus();
  };
  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

re-render を走らせたくない変数に使う

ja.reactjs.org/docs/hooks-faq.html#is-there-something-like-instance-variables
function Timer() {
  const intervalRef = useRef();

  useEffect(() => {
    const id = setInterval(() => {
      // ...
    });
    intervalRef.current = id;
    return () => {
      clearInterval(intervalRef.current);
    };
  });

  // ...
}

関数コンポーネントにおける、クラスのインスタンス変数として使える。

useRef はオブジェクトを作ってその参照を返すので、intervalRef.current の値を変更しても、intervalRef 自体が変更されないことから、re-render が走らない。この性質を利用して、クラスコンポーネントにおけるインスタンス変数のようなものとして扱うことができる。

references

Writings

blogsnippetcourse

Contact

Home©︎ suzukalight