はじめに
アドカレ20日目の記事です。
痛っっっったいタイトルですみません。(初参加なのに・・😇)
今回はReactNativeを触る上でつまづいたこととその解決策を記事にしたいと思います。
おかしな点などあれば、優しくご指摘頂ければ幸いです(><)それではいきます!
対象読者
- ReactNativeに興味がある方
- ReactNativeを始めたばかりの方
- ReactNativeのしんどい部分について共感したい方
序章:ReactNativeを触るようになった経緯
某プロジェクトで社内用のUIFWのようなものを作るにあたって、自作コンポーネントをReactとReactNativeでそれぞれ用意する必要がありました。ここで簡単にReactとReactNativeの違いを説明すると、Reactは主にWebアプリのUIを構築するためのFW、ReactNativeはMobileのUIを構築するためのFWです。こちらで詳しく説明されています↓↓
https://devskiller.com/jp/%E3%83%AA%E3%82%A2%E3%82%AF%E3%83%88%E3%81%A8%E3%83%AA%E3%82%A2%E3%82%AF%E3%83%88%E3%83%8D%E3%82%A4%E3%83%86%E3%82%A3%E3%83%96%E3%81%AE%E9%81%95%E3%81%84%E3%81%AF%EF%BC%9F/#what-is-the-difference-between-react-vs-react-native
ReactNativeはベースがReactなので、Reactを学んだことがある人にとってはかなりとっつきやすいFWと言えます。さらっと公式のドキュメントを確認して、全然読めるじゃん!余裕やな!!と思い、早速、環境構築をしてみることに。
第一章:環境構築
ReactNativeの環境構築には大きく分けて二種類方法があります。
1.素のReactNativeを使って構築する
2.ReactNativeをラップしたライブラリ(Expoなど)を使って構築する
私は迷わず、1の方法を取りました。理由としては色々あるのですが、大きな理由としては最新のバージョンでReactNativeを触りたかったのと、ライブラリを使うことで、ただでさえ大変なバージョンの追従がワンクッション挟むことでさらに大変になると考えたためです。というかこの時点ではライブラリを使う旨みもよくわかっていませんでした。ちなみにこの後、かなり痛い目をみることになります。
素のReactNativeを使って環境構築する記事がまるでない!!!!!
本当にないです。日本語の記事がないとかそういうレベルじゃないです。海外の記事も本当にない。これは余談ですが、monorepo環境でReactNativeを動かそうとしていたのもあって余計に記事が見つからず。特にバグった場合の記事がない。(なぜライブラリが重宝されるのか理由がわかった気がする。。。)
その後はなんやかんやあってExpoを使っているのですが、記事の多さが段違い、環境構築の手軽さ(スピード)が段違いでした。先ほど言ったバージョンの追従の問題はついて回るのですが、それをしてもいいと思えるほど、素のReactNativeを使った環境構築は大変です。それでは、手順をご紹介します。
・・と、思ったのですが、めちゃくちゃ長くなりそうだったので別途記事にします。ごめんなさい。
第二章:コンポーネント作成
さて、死ぬ気で環境構築を終わらせたので早速コンポーネントの作成に移りましょう。ReactNativeではいわゆるhtmlのタグは使わず、ReactNative側で提供しているコンポーネントを使ってコーディングを進めていきます。個人的によく使うのは以下のもの。これだけ押さえておけば大体はいけるはず。
コンポーネント名 | 説明 |
---|---|
View | コンテナ。divのようなもの。コンテンツはこれでWrapする。 |
ScrollView | このコンポーネントで囲んだコンテンツはスクロールが可能になる。 |
TouchableOpacity | onPress処理(WebでいうonClick)を行いたい場所に使用する。press時にopacityを変更する。 |
TouchableHighlight | onPress処理(WebでいうonClick)を行いたい場所に使用する。press時にhighlightが効く。 |
StyleSheet | スタイリングを行う場合に使用する。 |
Text | labelやpのようなもの。テキストを表示する場合に使用する。 |
TextInput | inputを作成する場合に使用する。 |
詳細はこちら => https://reactnative.dev/docs/components-and-apis
早速いくつかコンポーネントを作成してみましょう。
今回はbuttonコンポーネントとinputコンポーネントを作成します。(スタイリング周り、雑ですみません( ..))
動作環境
https://snack.expo.dev/@k.sugawara/f1f6a1?platform=ios
Button/Inputそれぞれでpress時/focus時/disabled時にスタイルを変更しています。
import React, { useState } from 'react';
import { Text, View, StyleSheet, GestureResponderEvent, TouchableOpacity } from 'react-native';
interface ButtonProps {
label: string;
disabled?: boolean;
onPress: ((event: GestureResponderEvent) => void);
}
// ReactNativeにはButtonコンポーネントが用意されていますがあえてTouchableOpacityを使用しています。
export const Button: React.FC<ButtonProps> = ({
label,
disabled,
onPress,
}) => {
const [isPressing, setIsPressing] = useState(false);
const style = disabled
? [styles.button_wrapper, styles.button_wrapper_disabled]
: isPressing
? [styles.button_wrapper, styles.button_wrapper_pressing]
: [styles.button_wrapper];
return (
<View style={styles.container}>
<TouchableOpacity
onPress={onPress}
onPressIn={() => setIsPressing(true)}
onPressOut={() => setIsPressing(false)}
disabled={disabled}
>
<View style={style}>
<Text
disabled={disabled}
style={[styles.paragraph, disabled ? styles.paragraph_disabled : styles.paragraph_default]}
>
{label}
</Text>
</View>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
padding: 24,
},
paragraph: {
fontSize: 14,
fontWeight: 'bold',
},
paragraph_default: {
color: '#10a8eb',
},
paragraph_disabled: {
color: '#fff',
},
button_wrapper: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
height: 40,
backgroundColor: '#fff',
padding: '0, 10',
borderRadius: 4,
},
button_wrapper_pressing: {
backgroundColor: '#abd2dd',
},
button_wrapper_disabled: {
backgroundColor: '#cbd2dd',
},
});
import React, { useState } from 'react';
import { View, TextInput, StyleSheet } from 'react-native';
interface InputProps {
value: string;
onChangeText: (value: string) => void;
defaultValue?: string;
placeholder?: string;
hasError?: boolean;
disabled?: boolean;
}
export const Input: React.FC<InputProps> = ({
value,
defaultValue,
placeholder,
hasError,
disabled,
onChangeText,
}) => {
const [isPressing, setIsPressing] = useState(false);
const [isFocus, setIsFocus] = useState(false);
const wrapperStyle = () => {
const style: any[] = [styles.input_wrapper];
if (disabled) {
style.push([styles.input_wrapper_disabled]);
} else {
if (hasError) {
style.push([styles.input_wrapper_error]);
if (isFocus) style.push([styles.input_wrapper_error_focus]);
if (isPressing) style.push([styles.input_wrapper_error_pressing]);
}
if (!hasError) {
style.push([styles.input_wrapper_default]);
if (isFocus) style.push([styles.input_wrapper_default_focus]);
if (isPressing) style.push([styles.input_wrapper_default_pressing]);
}
}
return style;
};
return (
<View style={styles.container}>
<View style={wrapperStyle()}>
<TextInput
style={[styles.input, disabled && styles.input_disabled]}
value={value}
defaultValue={defaultValue}
placeholder={placeholder}
editable={!disabled}
onChangeText={onChangeText}
onPressIn={() => setIsPressing(true)}
onPressOut={() => setIsPressing(false)}
onFocus={() => setIsFocus(true)}
onEndEditing={() => setIsFocus(false)}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
padding: 10,
},
input_wrapper: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
padding: 0,
backgroundColor: '#fff',
borderStyle: 'solid',
borderWidth: 1,
borderRadius: 4,
},
input_wrapper_default: {
borderColor: '#cbd2dd',
},
input_wrapper_default_pressing: {
borderColor: '#7f8c9d',
},
input_wrapper_default_focus: {
borderColor: '#10a8eb',
},
input_wrapper_error: {
borderColor: '#fa5252',
},
input_wrapper_error_pressing: {
borderColor: '#7f8c9d',
},
input_wrapper_error_focus: {
borderColor: '#fa5252',
},
input_wrapper_disabled: {
borderColor: '#ccd2dd',
backgroundColor: '#g7f9fb',
},
input: {
border: 'none',
outline: 'none',
textOverflow: 'ellipsis',
width: '100%',
height: 40,
padding: '0 10 0 10',
borderRadius: 4,
color: 'black',
fontSize: 14,
},
input_disabled: {
color: '#ccd2dd'
},
});
第三章:スタイリング
ReactNativeの最大の難点はスタイリングの難しさにあります。独特のルールや記法があり、Webと同じようにスタイリングしようとしても上手くいきません。私が躓いたケースをいくつか紹介します。
flex-boxについて
ReactNativeのスタイリングはFlexboxを基準として行われ、デフォルトでWebでいうところのdisplay:flex; flexDirection: column;
が効いている状態です。また、要素のサイズを決めるflexはViewコンポーネントにしか効きません。
正直、これらを理解せずにReactNativeのスタイリングは叶いません。要素を並べて表示する時、サイズを指定する時、絶対にハマります。この部分に関しては、着手前に必ず理解してから始めることを非常にお勧めします。
https://reactnative.dev/docs/flexbox
公式ドキュメントとは別に、わかりやすく記事をまとめてくださっている方がいらっしゃるのでこちらも是非参考にしてみてください!
https://zenn.dev/camcam_lemon/articles/d182e76ca1ea1c#react-native-%E3%81%A7%E3%81%AF%E3%83%87%E3%83%95%E3%82%A9%E3%83%AB%E3%83%88%E3%81%8C%E3%83%95%E3%83%AC%E3%83%83%E3%82%AF%E3%82%B9%E3%83%9C%E3%83%83%E3%82%AF%E3%82%B9
font-faceについて
ReactNativeではfont-faceの指定ができません。これによって何が問題になるかというと、例えば文字と数字でfont-familyを変えるといったことが実現できません。(もっと厳密にいうと、unicode-rangeが使えません。)また、googlefontの使用方法が若干手間です。参考程度に、以下にgooglefontを適用するためのコードを記載します。(比較用にweb版も載せています。)
const mavenPro500Woff = require('/fonts/MavenPro-Medium.woff');
/* maven-pro-500 - latin */
@font-face {
font-family: 'Maven Pro';
font-style: normal;
font-weight: 500;
src: local(''),
url(${mavenPro500Woff}) format('woff');
unicode-range: U+0030-0039;
font-display: swap;
}
~~~~~
`;
~~~~~
// ios/Fonts配下にDLファイルを格納
<key>UIAppFonts</key>
<array>
<string>Fonts/MavenPro-Medium.ttf</string>
<string>Fonts/NotoSansJP-Medium.otf</string>
</array>
~~~~~
=> 使用時はfontFamily: 'NotoSansJP-Medium'
のようにファイル名を指定する
import * as Font from 'expo-font';
const MavenProMedium = require('/fonts/MavenPro-Medium.ttf');
const NotoSansJPMedium = require('/fonts/NotoSansJP-Medium.otf');
// react-native(expo) build時で呼び出す際に指定する
export const loadFonts = async () => await Font.loadAsync({
'MavenPro-Medium': MavenProMedium,
'NotoSansJP-Medium': NotoSansJPMedium,
});
// storybookでbuildの場合
const StorybookUI = getStorybookUI({});
export default function StorybookUIRoot() {
const [IsReady, SetIsReady] = useState(false);
const LoadFonts = async () => {
await loadFonts();
};
if (!IsReady) {
return (
<AppLoading
startAsync={LoadFonts}
onFinish={() => SetIsReady(true)}
onError={() => { }}
/>
);
}
return <StorybookUI />;
}
=> 使用時はfontFamily: 'NotoSansJP-Medium'
のようにファイル名を指定する
line-heightについて
ReactNativeでline-heightを指定する場合、以下の問題が発生します。
-
line-height <= font-size
の場合にテキストが見切れる -
line-height > font-size
の場合に、テキストがline-height内で中央揃えにならず、line-height - font-size
分、下部に空白ができる
現状、どちらも明確な解決策は見つからず。(line-heightを指定しないことでしか解決できていない状況です。)知見のある方、どのような対策をされているかご教授いただけますと幸いです。
box-shadowについて
ReactNativeはbox-shadowについて大きく分けて二つ制約があります。
- 下記のように、カンマ区切りで複数指定することができない
ex)box-shadow: 3px 3px red, -1em 0 .4em olive;
- shadowは要素に対してxy軸にしか指定できない(要素をぐるっと囲うようなshadowはつけられない)
https://www.bad-company.jp/react-native-shadow
これについてはそれぞれ解決策があります。
まず1つ目の複数指定する方法から見ていきましょう。これついてはかなり気持ち悪い解決策となってしまうのですが、増やしたいshadow分、コンポーネントを増やしてそれぞれにスタイルを指定します。
export const ShadowMultiple: React.FC = () => {
return (
<View style={styles.shadow1}>
<View style={styles.shadow2}>
<View style={styles.shadow3}>
<TouchableOpacity style={styles.shadow4}>
// コンテンツ
</TouchableOpacity>
</View>
</View>
</View>
);
};
const styles = StyleSheet.create({
shadow1: {
shadowColor: '#1e242c',
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.05,
shadowRadius: 8,
},
shadow2: {
shadowColor: '#1e242c',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.06,
shadowRadius: 4,
},
shadow3: {
shadowColor: '#1e242c',
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.08,
shadowRadius: 2,
},
shadow4: {
shadowColor: '#ffffff',
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.25,
shadowRadius: 1,
},
});
次に2つ目の問題ですが、これはReactNativeの仕様上どうにもならない問題なので、shadowの使用を諦めます。代わりにborderを使用します。
// shadow(NG)
shadow: {
shadowColor: '#fff',
shadowOffset: {
width: 0,
height: 0,
},
shadowOpacity: 1,
shadowRadius: 4,
},
// border(OK)
borderColor: #ffffff;
borderWidth: 4;
borderRadius: 4;
まとめ
環境構築やスタイリングについては、どんな言語・FWを使う時でも、躓くところではないかなと思います。環境構築なら作業量の多さと複雑さ、必要なFWやツールの精査と他ライブラリとの組み合わせの良し悪しを考えたり、スタイリングならロジックを書くよりも個性が出やすい上、ハック的な手法も多く見られます。そう考えるとReactNativeだけが大変というよりはそれそのものが大変なだけなのかもしれません。個人的な話ですが、フロントエンドのFWの中ではReactが大好きなので、ReactNativeの今後の活躍に期待したいところです。
ReactNativeのことを、胸を張って大好きと言えるようにもっと精進します!!