🎨

React Nativeのコンポーネント設計

2023/12/13に公開

これはCureApp Advent Calendar 2023 13日目の記事となります。

はじめに

React Nativeのコンポーネントは設計は、基本的にウェブのReactで良いとされるコンポーネント設計をそのまま活かすことができます。
ディレクトリー構成、Container/Presentationパターン等々、既に提唱されている設計手法はReact Nativeでも有効です。
この記事では、ウェブとの違いを抑えつつ、ネイティブアプリに特化したコンポーネント設計を解説します。
飽くまで筆者はこう考えているよという内容だということは、ご了承ください。

ウェブとの違いを理解する

まずはネイティブアプリ固有の特性を知ることからです。
この特性を押さえることで、良い設計とはがぼんやり見えてきます。

画面サイズ

ウェブアプリとネイティブアプリの最大の違いと言えば、ベースとなる画面サイズになります。

Figma等のデザインツールでは、ウェブアプリだと1920 x 1080や1440 x 1024あたりがベースとなっていることが多いです。
ネイティブアプリの場合、現在最も大きい端末でもiPhone 14, iPhone 15の430 x 932です。
逆に最も小さい端末はiPhone 8の375 x 667です。
もちろんもっと小さい端末(初代iPhone SE)や大きい端末(iPad)もありますが、メジャー端末の下限と上限はこのあたりと言えるでしょう。

ウェブアプリと比べてネイティブアプリは一度に表示できる情報が、殆どのケースにおいて少なくなります。
ウェブでは1つの画面で表示できていたものも、ネイティブアプリではページを2,3ページに分割することは珍しくありません。

つまり、1画面という単位で比較した時、ネイティブアプリはUIとロジックが少ないという特徴があります。

スタイリング

ウェブではdisplayプロパティにgridやflex、inline-blockなど色々なプロパティが用意されています。
React Nativeでは全てフレックスボックスでレイアウトすることが強制されます。
全てをフレックスボックスで表現するということは、目的のデザインを実装する上での既存のCSSの実装Tipsがほぼ消失することを意味しています。

displayはflexnoneしか用意されておらず、一部CSSの初期値も違います。
下記プロパティがViewやText要素に初期値として設定されていると考えてください。

display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
align-self: auto;

/* React NativeのCSSには存在しませんが、同じ振舞いをしています */
box-sizing: border-box;

要約すると、デフォルトでコンポーネントは縦方向に積み上げられ、paddingとborderは要素の内側に適用されていきます。
これがそのままコンポーネント設計にも引きずられていきます。
ウェブにおける汎用性が高く経年劣化に強いコンポーネントは、React Nativeの世界では趣きが異なります。

フォントサイズ

フォントサイズはOSやブラウザの設定で変更することができます。
もちろんiPhoneとAndroidもOSの設定でフォントサイズ(iOSではDynamic Typeと呼ばれています)を変更することができるようになっています。
この設定はウェブで詳細な値を取得することができませんが、ネイティブアプリではfontScaleという設定値で取得することができるようになっています。
React NativeだとPixelRatio.getFontScale()というAPIから取得することができます。

なお、iOSではアクセシビリティの項目から、更に大きくすることができるようになっており、デフォルトサイズから最大で約3.1倍まで大きくすることができます。
ここまで大きくなると、ウェブでいうところのレスポンシブデザインはほぼ通用しません。
ただ大きくすればいいという話ではなく、大きくなりすぎて意味のある情報として視認することも難しくなります。
スケール値は制限することができるため、要所要所で適切なスケール値を設定しておきましょう。

話の本筋から少し逸れますが、超高齢社会の日本ではフォントサイズを大きくしてスマホを使用している人は決して少なくはありません。
正確な統計データが出ているわけではありませんが、自分のお父さんお母さん世代のスマホ画面を一度見せてもらうといいかもしれません。

画面スクロール

ウェブアプリでは、画面に収まりきれない場合自動でスクロールするようになります。
ネイティブアプリの場合はScrollViewを使用しないと、画面スクロールが発生しません。
たとえ画面に要素が収まりきれなくてもスクロールが発生しないという点に注意してください。
前項のフォントサイズの問題と特に相性が悪く、フォントサイズを大きくするとアプリの進行ができなくなるという致命的なバグがいとも容易く再現します。

これはデザイナーにも言えることですが、フォントサイズに最適化されたデザインと実装はしないようにしましょう。
スクロールすることを前提に画面は作成しましょう。
スクロールしないことに意味があるのであれば、フォントスケーリングを切るというのも有効な選択肢となります。

セーフエリア

これについてはウェブを生業としているエンジニアの方がよく知っているかもしれませんね。
iPhone Xが登場以降、端末の画面上部に「ノッチ」と呼ばれるエリアが組み込まれた端末がデファクトとなりました。
ちなみに、iPhone 14以降ではノッチは廃止されダイナミックアイランドという名称で生まれ変わりました(従来のノッチとも少し違います)。
多くの場合、この箇所にはUIが重ならないようにすることが推奨されています。
React NativeにもSafeAreaViewというコンポーネントが提供されており、こちらを使用することで重ならないようにしてくれます。

react-navigationとSafeAreaView

react-navigationのStackやBottomTabからレンダリングしているスクリーンは、デフォルトでSafeAreaViewが適用されているので、自前で設定する必要はありません。
Stack内でSafeAreaViewを囲むと、二重で余白がつけられるので注意しましょう。

しかし、Stackは画面上部にのみ、BottomTabは画面上部と下部にのみSafeAreaViewを適用するという落とし穴があります。
また、Propsでヘッダーを非表示にした場合も画面上部のセーフエリアとセットで非表示にするという二段構えの落とし穴です。
なので、場合によってはSafeAreaViewは自前で設定することもちょいちょい必要となってきます。

なお、react-navigationはreact-native-safe-area-contextのSafeAreaViewを使用しています。
こちらの方が活発にメンテナンスもされており、より細かな設定を行うことができます。
自前で設定する際はreact-native-safe-area-contextの方を使いましょう。

どういう設計がいいか

筆者は、レイアウトを決めるためのレイアウト層と、実際に何かをレンダリングするプレゼンテーション層をベースにコンポーネントを設計していけば良いと考えています。
Container/Presentationパターンになぞらえて、ここではLayout/Presentationパターンとでも名付けましょうか。
このパターンではContainer(ロジック)をどこに持つかは言及しません。
Layout/PresentationパターンはContainerには関心を持たないため、一番親でまとめても、必要な箇所で適宜呼び出すこともできます。

Layout/Presentationの概略

React Nativeの世界では、いかにフレックスのルールを崩さずにコンポーネントを積み上げていくかが大切になります。
全てをフレックスボックスで表現する制約があり、その制約を守るためだけの責務を全てLayout層に担ってもらいます。

Layoutは必ずPresentationを子に持ち、PresentationはLayoutを更にネストすることもあります。
この場合、前者はスクリーン全体のレイアウトを、後者はコンポーネントのレイアウトを表現することを意味しています。
おおよそ、下記のようなコンポーネント設計になるイメージですね。

// ここはレイアウトとコンポーネントの配置を決めるだけなので実態はヘッドレスコンポーネントになる
const Screen = () => (
  <Layout>
    <Layout>
      <Presentation1 />
    </Layout>
    <Layout>
      <Presentation2 />
    </Layout>
    <Layout>
      <Presentation3 />
    </Layout>
  </Layout>
);

const Presentation1 = () => <Component />;
const Presentation2 = () => (
  <Layout>
    <Layout>
      <Component />
    </Layout>
    <Layout>
      <Component />
    </Layout>
  </Layout>
);
const Presentation3 = () => (
  <Layout>
    <Component />
  </Layout>
);

UIをLayoutとPresentationで必ずわけるので、コンポーネント数は多くなりがちです。
ですが画面サイズが小さいので、1画面での累計のコンポーネント数も小さくなりがちなので、それで相殺する狙いです。
とりわけ複雑性が増しやすいLayoutも、フレックスレイアウトオンリーなので、むしろ簡素と言えます。
この設計では、コンポーネントの配置や余白を実装する際はLayoutを触り、描画されるものを実装する際はPresentationを触るという一律の志向性に向かいます。

この考え方はウェブのReactでも通用しますが、gridだったりflexだったりブロック要素だったりインライン要素だったりと考慮すべきレイアウトが多く、カオスになりがちです。
また、一度敷いたルールを簡単に上書き・脱法することも可能なのも難しい点となります。

React Nativeではただ1つのスタイリングと、画面サイズの制約でこの設計が活きてくると筆者は考えています。

Layout Component

特定のUIを持たず、単体では意味のある情報を伝えることができないコンポーネントです。
子のコンポーネント(children)をどのように配置するかのみを責務に持ちます。
このコンポーネントは基本的にViewまたはScrollView要素で作成するため、fontSizeやcolorなどのプロパティを受け付けません。
レイアウトは大きくわけて、3つの種類に分類されます。

フレックスボックスのプロパティ

  • flex
  • flexGrow(※1)
  • flexBasis(※1)
  • flexShrink(※2)
  • alignContent
  • alignItems
  • alignSelf
  • justifyContent

相対/絶対指定のプロパティ

  • position
  • top
  • bottom
  • right
  • left

余白のプロパティ

  • gap
  • margin
  • padding

※1:widthやheightの指定として、Presentationでも使う
※2:テキストが適切に改行するために、Presentationでも使う

widthとheight

レイアウトとして使用することは基本的にありません。
widthはflex, flexGrowで比率または伸び率として表現し、heightは子の要素分だけ確保(height: "auto")されるようにします。
画面の一部分だけスクロールできるようにしたりなどは、flexBasisで高さを確保しましょう。

フレックスレイアウトでは、widthとheightよりもフレックスレイアウトで定められたルールの方が優先されます。
width: 100%をかけたのに横幅いっぱいに伸びなかったり、他の要素があると指定した数値よりも小さくなったりといった現象はこれが原因となります。

Presentation Component

必ず目視可能な意味のある情報を伝えるUIを持つコンポーネントです。
ボタンやテキスト、テキストフィールドなどがこのプレゼンテーションに分類されます。
Material UIでいうところのPaperやCard、Listといったコンポーネントもこちらに分類されます。

プレゼンテーションでは明示的にwidthやheightを指定することがありますが、これらはflexGrowおよびflexBasisで表現することも可能です。
例えば、width: 70%のような指定は(親で領域が確保されていれば)flexGrow: 0.7と等価で、width: "200px"のような指定はflexBasis: 200と等価になります。

1点注意すべき点として、スタイリングの項目で書いたようにプレゼンテーションであっても以下のレイアウトは適用されています。

display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
align-self: auto;

/* React NativeのCSSには存在しませんが、同じ振舞いをしています */
box-sizing: border-box;

widthとheightの数値指定は避ける

widthとheightの数値指定はダメというわけではありませんが、fontScaleが上がるとデザイン崩れの原因となるので注意が必要です。
幅が200px,高さが64pxのボタンの中央にtextと表示したボタンがあるとします。
幅と高さを決め打ちにすると、iOSの最大サイズのfontScaleを適用した時によくわからないボタンになってしまいます。

中央にtextと書かれた白色のボタン

文字見切れてほぼ白色のボタン

このように簡単に見た目が崩壊して、ユーザに何も伝えられないデザインへと変わってしまいます。
heightの指定は消して、paddingを設定するようにして回避しましょう。
このボタンの見た目はさておき、最低限伝えたい情報が削ぎ落とされないようにしておくのが大事です。

文字見切れてほぼ白色のボタン

ベースの幅や高さを持たせたい場合、flexBasisやmin-widthとmin-heightを使うようにしましょう。
なお、min-widthとmin-heightを使えば大丈夫という話にならないのが厄介なところで、フォントサイズは大きくもできますが小さくもできます。
小さくなった時に着膨れしたボタンになるので、そこは注意しましょう。

できるだけ親要素のフレックスレイアウトの伸び率を元に相対指定にすることが、経年劣化に耐えうるプレゼンテーションの設計へとつながります。

GitHubで編集を提案
CureApp テックブログ

Discussion