注意! この記事はQiitaにて公開されていた内容をimportしたものです。
これらの内容は場合によっては陳腐化していて役に立たなくなっていたり、有害であったり、現在の著者の主張と異なることがあります。
皆様の判断の上でご利用いただけますと幸いです(度を超してヤバいものは著者に連絡して頂ければ対応します m(_ _)m)


この記事は React Native Advent Calendar 2019 の6日目の記事です。

オトバンクにて audiobook.jp のモバイルアプリ(iOS/Android)版の開発をしている @pside と申します。

先日、audiobook.jp アプリを更新し、iPadにおいてヨコ画面、いわゆるlandscape modeに対応しました。

この記事では、audiobook.jpアプリにおいて施したlandscape modeの実装方法を解説したく思います。 この記事を読んで、皆さんのReact Nativeアプリもlandscape modeをサポートしてみてはいかがでしょうか?

注意: この記事ではiPadのlandscape modeにのみ言及しています。iPhoneにおいても動作するはずです。 いっぽうでAndroidのlandscapeに関しては動作確認をしていない為、今回は対象外とさせてください(もしかしたら動くかも知れませんが自信ないです)。

1. そもそもReact Nativeの初期テンプレではiPad対応してないので有効化する

現在の RN 0.61.5 な環境において、 react-native init RNSandbox をした直後のiOSプロジェクトにおいては、iPadに対応していません。 (以降、特に断りがない限り RNSandbox がプロジェクト名となります。)

ios/RNSandbox.xcworkspace を開き、Project navigatorからRNSandboxを選択し、GeneralタブのTARGETSからRNSandboxを選び、Deployment InfoにてiPadのチェックをつけます。 あわせて Device Orientation を確認し、Landscape LeftおよびLandscape RightのチェックがONになっているかを確認します。

スクリーンショット 2019-12-08 00.13.45.png ▲ この図では iPad のところにチェックがないので、 iPad のところにチェックをつける

なお、iPhoneではportrait固定だけどiPadではportrait/landscapeにしたいケースもあるかと思いますが、以下のStack Overflowの回答の通り、Infoタブ内の設定を行うことで実現できます。スマホのヨコ画面のUIは難易度が高めなので、あえて無効にするのもアリだと思います。

これだけで画面回転に対応することが出来ました。 ReactNativeのWelcome画面で試すと、このとおりイキイキしてますね!

画面収録 2019-12-08 0.35.26.2019-12-08 10_32_17.gif

2. landscapeモードでデザインが崩れていないか確認する

実際のアプリで壊れている箇所がないか確認します。 この時点では微調整など一切していないのでいろいろ壊れていることがあるかも知れません。

例えば、16:9比率の画像を横幅いっぱいに出すような、以下のレイアウトがあったとします。

const App = () => (
    <>
        <StatusBar barStyle="dark-content"/>
        <SafeAreaView style={styles.outer}>
            <View style={styles.banner}>
                <Text>Banner Area</Text>
            </View>
            <View style={styles.inner}>
                <Text>Hello, React Native</Text>
                <Text>Hello, React Native</Text>
                <Text>Hello, React Native</Text>
                <Text>Hello, React Native</Text>
                <Text>Hello, React Native</Text>
            </View>
        </SafeAreaView>
    </>
)

// TypeScriptの型ヒント用
function styleType<T>(obj: T): T {
    return obj
}

const styles = StyleSheet.create({
    outer: styleType<ViewStyle>({
        flex: 1,
        borderColor: "red",
        borderWidth: 4
    }),
    banner: styleType<ViewStyle>({
        // 16:9 っぽ画像を模倣する
        aspectRatio: 1.8,
        borderColor: "blue",
        borderWidth: 4
    }),
    inner: styleType<ViewStyle>({
        borderColor: "green",
        borderWidth: 4
    }),
})

これらのUIはportraitでは良い感じに表示されています。

スクリーンショット 2019-12-08 01.54.20.png

しかしlandscapeになった途端、Banner Areaが大きくなりすぎて下部のコンテンツが圧迫されてしまいます。

スクリーンショット 2019-12-08 01.54.55.png

これらの調整は実装内容に応じて解決アプローチが異なります。

例えば、幅・高さは固定値にするといった対応が考えられます。 なんらかのサムネイル画像などでは充分問題のない対応に思われます。 しかし、portrait/landscape双方でしっくりくる値にするのはなかなか難しいものです。

逆に、portrait/landscapeどちらも同じ値にすることを諦めて、 portraitとlandscapeで別のスタイルを適用する方法もあると思います。 これを実現するためには2つのことを実現する必要があります。

  1. コンポーネントが現在の回転状況(orientation)を受け取る
  2. orientationをもとにStyleSheetを差し替える

3. 画面回転の変更通知を受け取る

画面回転の変更通知を各コンポーネントが受けられるようにします。 弊社アプリでは react-redux を用いているため、redux stateの形で通知するようにしています。

断片で申し訳ないですが、こちらにredux-sagaでの実装例を置いておきます。

関数コンポーネントをお使いであれば、react-native-community/react-native-hooksuseDeviceOrientation を用いても良いでしょう。

ともあれ、何らかの形で現在のorientation値をとれるようにしてください。

(今回はスタイルに絞って話を書いていますが、Master-Detailパターン( https://webapphuddle.com/master-detail-ui-pattern-design/ )のようなものを実装する際のJSXの出し分けにも活用できると思います。)

4. orientationをもとにStyleSheetを差し替える

スタイルシートの差し替えは思いつく限り2通りの方法があります。

  1. portrait用, landscape用のスタイル定義をそれぞれ事前に用意しておく
  2. orientationに応じてStyleSheetを生成し、lodash.memoize等でメモ化する

私は見通しの良さから後者を採用しましたが、お試しでやるには前者の方が馴染みやすいと思います。

4-1. それぞれスタイルを事前に用意する

極めてシンプルなやり方です。 実際の定義で示したほうが早いと思います:

const App = () => {
  const orientation = useDeviceOrientation()
  return (
      <>
        <View style={orientation.portrait ? styles.bannerAreaPortrait : styles.bannerAreaPortrait} />
      </>
  )
}

const styles = StyleSheet.create({
  bannerAreaPortrait: {...},
  bannerAreaLandscape: {...},
})

4-2. StyleSheetを都度生成、かつメモ化

これはJSX構造があまり汚くなるのを避けたいが為に考えた方法です。

ご存じの方も多いと思いますが、 毎回 render() をするたびにスタイルシートをparseしているとパフォーマンスが良くないので、 StyleSheet.create() を用いて事前に生成処理を行い、結果をIDとして返却し、 ViewのstyleにはIDを渡すことで高速にスタイルを適用しています。

そのために柔軟性あるスタイルの書き換えと、事前生成の利点の両得を狙い、メモ化処理をするようにしました。 メモ化をしているので、べた書きするよりはパフォーマンスも悪くないはず(計測したわけではないですが理屈の上では問題ないかなと)。

const App = () => {
  const orientation = useDeviceOrientation()
  const styles = getStyles(orientation.landscape)
  return (
      <>
        <View style={styles.bannerArea} />
      </>
  )
}

const getStyles = _.memoize((isLandscape: boolean) => {
  return StyleSheet.create({
    bannerArea: {
      someProp: isLandscape ? 'landscape' : 'not-landscape'
    }
  })
}

その他の注意事項

  • 必ず実機でも確認しよう
    • 僕が実装をしていたタイミング(RN0.59くらいかな?)では、シミュレーターではうまくいくのに実機ではうまくいかないケースがありました。
    • しかし今回の記事を書くタイミングでRN0.61.5で追試をしたところ、なんかうまく動いてました :thinking_face: 1
  • 気長にやろう
    • landscape対応はそれなりに時間がかかります。案件の合間や暇なときに取り組むようにして気楽にやるくらいがちょうど良いと思います。

まとめ

実のところ、iPadのヨコ対応は1年前くらいからやりたいと思っていたのですが、なかなかうまくいかず、1年かかってしまいました。。。 とはいえ、諦めず気長に取り組んだ結果、なんとかヨコ画面対応をすることが出来ました。 自宅で使用しているiPad Proはふだんヨコ画面でしか使っていなかったので、自社アプリがヨコ画面対応したことでますます愛着が沸くようになりました。

というわけで、皆様のReact Nativeアプリのヨコ画面対応の手がかりになれば幸いです。

てなわけで、React Native Advent Calendar 2019、 9日目は @tkow さんです!


  1. 以前はStyleSheetの生成時(たいていアプリ起動時)のorientationで flex:1 などの幅・高さが決まってしまう(タテの幅高さがヨコにしても変化せずデザインが壊れる)ような挙動を示していた記憶があるのですが、今回追試をしたところ動いてしまい、RNのバージョンによるものなのか、NativeBaseのようなUIフレームワークが原因なのか分からず、そうこうしているうちに時間切れです。。。 ↩︎