Yukashikadoでは、React Nativeを使って、iOSとAndroidとモバイルアプリを開発しています。 皆さんは、figmaからモバイルアプリにデザインを落とし込むときに、端末に応じて、スタイルをかき分けて大変な思いをしたことがあるでしょうか?? 今回は、OSやデバイスの大きさに応じたスタイル分岐をせずに、一度だけスタイルを書けば完了するStyleの書き方について紹介します

課題感

figmaのスタイルを実装時に変更するというのは、 figmaの見た目とモバイルの見た目に不一致があるためです。 その発生パターンは以下のおおよそ2つパターンだと思います。

  • figmaと実装時の利用デバイスの画面幅の不一致によるスタイルの崩れ
  • 実装後、複数の異なる画面サイズやOSで確認したときにスタイルの崩れ

figmaと実装時の利用デバイスの画面幅の不一致

figma上では、画面幅を決めてデザインが作成されます。 Yukashikadoのfigma上では、モバイルのディスプレイ幅が375pxとして、デザインが作成されています。 開発時に使用している端末が、横幅のdipが375(iPhoneXなど)であれば、figmaの画面と実際の画面の大きさが一致します。 しかし、開発時に利用している端末の横幅のdipが異なれば、 figma上の1pxと画面上の1dipが一致しません。 何も考えずに作業をすると、 そこに発生するズレを、見ながら修正する作業が発生します。

ちなみにどれぐらい違うかというと、同じiPhoneでもこれだけ違います

iPhoneX: 375x812, iPhone15 Pro Max: 430x932 参考

実装後、複数の異なる画面サイズやOSで確認したときにスタイルの崩れ

モバイル端末の大きさは様々なことから、 iPhoneXで作っていい感じだった画面が、iPhone15 Pro Maxで見るといい感じではないようなことがおきます。 同じOSでも問題が発生してますが、AndroidとiOSではそれがより顕著に発生します。 画面サイズに加えて、 AndroidとiOSでデフォルトで使えるフォントが異なることから、それによって、見栄えが大きく異なることがあります。

どうやって解決するのか

以下の手順で解決できると思います。

  1. 基準となるデバイスを決める
  2. OSごとのフォントの方針をきめる
  3. 基準デバイスと実デバイスの差分をスケーリングさせる実装をする

1.基準となるデバイスを決める

デザイナーと開発者で、基準となるデバイスを決めます。 Yukashikadoのfigma上では、モバイルのディスプレイ幅が375pxとして、デザインが作成されているので、iPhoneXで375x812を基準としています。

2. OSごとのフォントの方針をきめる

OSごとにフォントの方針を決めます。 iOSは多種多様なデフォルトフォントが揃えられています 参考 しかし、Androidはrobotoとnotoがデフォルトで使えるフォントとして用意されていますが、ほかのフォントは機種によって異なります。 カスタムフォントを使うことによって、OSのデフォルトのフォントに依存しないやり方もあります。 yukashikadoでは、ユーザがおよそ8割がiPhoneユーザであることから、基本はiPhoneのフォントでfigmaを組み、iPhoneベースで実装し、Androidについてはシステムフォントを利用するという方針を取りました。 Androidでフォントによってデザインが崩れるのは、一旦妥協することにしています。 また、具体的なコードなどについは今回は割愛します。

3. 基準デバイスと実デバイスの差分をスケーリングさせる実装をする

ここからは具体的なやり方です。 差分をスケーリングするには、react-native-size-mattersというライブラリを使います。 このライブラリのありがたみをきちんと理解したい方は、この記事を一読するのをおすすめします。 このライブラリがやってくれることしてはは、dipを実デバイスと基準デザインに応じて、スケーリングできます。 たとえば、

  • (基準デバイス) iPhoneXで375x812
  • (実デバイス) iPhone15 Pro Maxで、430x932

としたときに、 15dipは、横幅をもとに、iPhoneXでは15dipですが、iPhone15 Pro Maxでは、17.14dip(15 * 430 / 375)として扱うことができます。

縦幅でスケールすることもできます。また、スケールの適応具合も調整することもできます。 個人的には、横幅をもとにスケールすることが正しくスケールできるのでそちらをおすすめします。

使い方は簡単で、StyleSheetの代わりに、ScaledSheetを使うことで、スケーリングされた値を使うことができます。 @sをつけることで、横幅をもとにスケーリングされます。 @vsをつけることで、縦幅をもとにスケーリングされます。 @msrをつけることで、丸めてくれます。


const styles = ScaledSheet.create({
    container: {
        width: '100@s', // = scale(100)
        height: '200@vs', // = verticalScale(200)
        padding: '2@msr', // = Math.round(moderateScale(2))
        margin: 5
    },
    row: {
        padding: '10@ms0.3', // = moderateScale(10, 0.3)
        width: '50@ms', // = moderateScale(50)
        height: '30@mvs0.3' // = moderateVerticalScale(30, 0.3)
    }
});

ただこのまま使うにはいくつか課題が残ります。 react-native-size-mattersは、350x680を基準デバイスにしています。 それは、ソースコード上に書き換えられています。 なので、patch-packageで、以下の値を決めた基準端末に書き換えてください。 lib/scaling-utils.js

const guidelineBaseWidth = 350; const guidelineBaseHeight = 680;

このまま使うと、iOSよりAndroidのほうが少しフォントサイズが大きいので、Androidで見たときに、文字がはみ出てたり、違和感が残ります。 なので、androidのフォントのときは、0.5dipさげてからスケーリングすることで、見た目を調整をおすすめします。

最後に使い心地の課題です。 通常のStyleSheet.createであれば、100と書けばいいところを、'100@s'と書く必要があり、普通にめんどくさいです。 flexやzIndexなどの画面の縦横以外の意味を持つプロパティは、のぞいて、すべてスケーリングしたいです。 なので、そのあたりを解決したStyleの作成関数を作ります。以下のとおりです。


import {
  NamedStyles,
  scale,
  ScaledSheet,
  Size,
} from 'react-native-size-matters'
import { Dimensions, Platform, RegisteredStyle } from 'react-native'

/**
 * スケールを適用させたくないプロパティ
 * これら以外は、100と指定したときに、`100@s`としてスケーリングされる
 */
const excludedProperties = [
  'aspectRatio',
  'flex',
  'flexGrow',
  'flexShrink',
  'flexBasis',
  'zIndex',
  'opacity',
]

/**
 * フォントで特殊対応しなければ行けないプロパティ
 */
const fontScaleProperties = ['fontSize', 'lineHeight']
/**
 * プラットフォームの差を吸収する
 */
const fontDiff =
  Platform.select({
    ios: 0,
    android: -0.5, // Androidのほうが大きくなるので、小さくする
  }) || 0

/**
 * スタイルシートの型定義。styles.xxxから正しく補完させる 
 */
type TStyleSheet<T> = {
  [P in keyof T]: RegisteredStyle<{
    [S in keyof T[P]]: T[P][S] extends Size ? number : T[P][S]
  }>
}

/**
 * 標準デバイスに合わせてスケーリングしたStyleを作成する
 * @param stylesObject
 */
export const autoScaleStyles = <
  T extends NamedStyles<T> | NamedStyles<Record<string, unknown>>,
>(
  stylesObject: T,
): TStyleSheet<T> => {
  const scaledObject: Record<string, Record<string, unknown>> = {}

  // 各スタイルオブジェクトを取得して、ループする
  for (const styleName in stylesObject) {
    scaledObject[styleName] = {}

    // 各プロパティを取得して、ループする
    for (const prop in stylesObject[styleName]) {
      const value = stylesObject[styleName][prop]

      // 数字で、かつ、スケール対象のプロパティ
      const isScaleProperty =
        !excludedProperties.includes(prop) && typeof value === 'number'

      if (isScaleProperty) {
        if (fontScaleProperties.includes(prop)) {
          // フォントの差分を吸収する
          scaledObject[styleName][prop] = scale(value + fontDiff) // Androidで、lineHeightが@sできかない
        } else {
          // 数字の場合、@sを追加
          scaledObject[styleName][prop] = `${value}@s`
        }
      } else {
        // スケール対象ではないのでそのまま代入
        scaledObject[styleName][prop] = value
      }
    }
  }

  return ScaledSheet.create<T>(scaledObject as T)
}

上記のコードをつかうことで、以下のように、styles.top_imageはスケール済みの値として使うことができます。

const styles = autoScaleStyles({
  top_image: {
    width: 100,
    height: 100,
  },
})

結果

別の画面ですが、AndroidとiOSでこのような画面になりました。 AndroidとiOSでフォントが異なるので違う感じがしますが、おおよそいい感じがしています。

Android and iPhone

最後に

ReactNativeで、デバイスやOSに依存しないスタイルの書き方について紹介しました。
figmaから、スタイルを一度書くだけで済むようになったので、だいぶスタイルが楽になったと思います。 もっとより良いやり方があれば、また記事を書きたいと思います。 ありがとうございました。