soranoba
soranoba Author of soranoba.net
programming

react-router-nativeでタブナビゲーター+スワイプ対応をする

react-routerのネイティブ用実装であるreact-router-nativeでスワイプ対応のボトムタブナビゲーションを実装してみました.

そもそもReactNativeのnavigation用のライブラリのいくつかはスワイプアニメーションに既に対応しています.
それらのライブラリを使わず, react-router-nativeを使った理由について, まずは説明します.

自分がナビゲーターに求める機能とその理由は以下の5点です.

  • タブ, スタックに対応できる
  • スワイプによる切り替えができる
    • 別のアクションを入れる場合もあるが, UX的にスワイプ対応は必須だと思っているから
  • スクリーンのマウントタイミングが切り替え時であること
    • componentDidMount時にリスト要素の取得をする場合, 余計なViewの読み込みをされると困る
  • UIのカスタマイズ性
    • 特定のUIしか提供できない場合, そのライブラリを使い続けるのが厳しくなる局面が想定される為
  • webにも対応している
    • ReactNativeを触ったモチベーションの1つにweb, nativeのコードシェアリングがあるので, 共通化できると好印象
    • ナビゲーション周りはwebと挙動が変わる可能性は高いので必須ではない

react-navigation

ReactNativeは公式でreact-navigationを推奨しています.
実際, 簡単にスタックやタブナビゲーターを簡単に作成でき, 派生ライブラリでスワイプアニメーションにも対応できます.
ただ, タブのカスタマイズ性はイマイチだなと感じました.

デフォルトの状態でLandscapeにするとこんな感じの見た目になります.


もう少し高さが欲しくて, 文字はアイコンの下が良いと思った場合に, 恐らくtabBarComponentを指定することで調整は可能だと思われますが, propsの仕様はコードを読むしかなく (かつ追いづらい), この時点で候補から外しました.

また, webに対応してるようなことが書かれていますが, 対応していません.
対応するPRが一部出ていますが, これだけでは足りなさそうなので, webで使うのは現時点では避けたほうが無難です.

react-navigation-fluid-transitions, react-native-router-flux

FluidTransitionsreact-native-router-fluxはreact-navigationを依存関係に持つアニメーションライブラリと拡張ライブラリです.

react-navigationを候補から外したと同時に, これらも候補から外しました.

react-native-swiper

react-native-swiperは簡単にスワイプ対応ができる便利なライブラリです.
使い勝手はとても良いのですが, マウントタイミングの制御に難がありました. 同時保持スクリーン数は制御できるものの, スワイプする前に読み込ませることができず (スワイプ中はインジケータが表示される), 候補から外しました.

また, ナビゲーションライブラリではないので, ルーティングは自分で実装する必要があります.

react-router

react-routerはURLパスからのルーティングに対応している主にweb側で使われている (と思われる) ルーティングライブラリです.
ナビゲーションを提供するというよりは, リンクを貼る為のコンポーネントや遷移した際の画面切り替え用のコンポーネントが用意されている為, これを使って自分でナビゲーションを作る形になります.

手間は多いですが, ルーティングに重きを置いた設計思想は好印象です. また, web, nativeの両方への対応が同じレポジトリ内 (但しパッケージは別) で行われている為, 一緒にメンテされそうです.

UIは自分で作るので, 当然スワイプの対応もありませんが, そのぐらいやったらーということで, 実装するに至りました.

他のライブラリ

他にもいくつかあることは知っていますが, 調査しませんでした.
この時点で大分時間を持っていかれていたので疲れたというのと, react-routerをweb側に使うことはURLパスルーティング(?)の関係で, ほぼほぼ確定しており, react-router-nativeで可能なら実装したかったという思いもあります.

react-router-nativeでのスワイプ実装

実行フロー

さて本題です. 設計自体は割とシンプルに以下の手順です.
これに加えてスワイプによる遷移のトリガーを追加すれば大丈夫そうです.

  1. flex:1でViewを置く
  2. 1のViewのonLayoutで横幅, 縦幅のサイズを取得する
  3. 2で取得した横幅x3のViewを用意し, 表示位置を右/左に移動することでアニメーションを行う

実装

/**
 * @author Hinagiku Soranoba <soranoba@gmail.com>
 * @license MIT
 */
import React from 'react';
import { View, Animated, PanResponder } from 'react-native';
import { withRouter, Switch } from 'react-router-native';
import PropTypes from 'prop-types';

/**
 * Returns an array of path for children.
 * @param {!Array.<Route>} children
 * @returns {!Array.<String>}
 */
function getRoutePaths(children) {
  return React.Children.map(children, child => child.props.path);
}

/**
 * It is a container view that supports switching Route with swipe.
 * It animated swipe when Route switches.
 *
 * Usage:
 *   <SwipableRouteContainer>
 *     <Route exact key={key} path={path} component={component} />
 *     <Route exact key={key} path={path} component={component} />
 *   </SwipableRouteContainer>
 */
class SwipeableRouteContainer extends React.Component {
  static propTypes = {
    children: PropTypes.node.isRequired,

    // The following props are provide by `withRouter`
    match: PropTypes.object.isRequired,
    location: PropTypes.object.isRequired,
    history: PropTypes.object.isRequired,
  };
  constructor(props) {
    super(props);

    this.onLayout = this.onLayout.bind(this);
    this.onAnimationEnd = this.onAnimationEnd.bind(this);
    this.handlePanResponderMove = this.handlePanResponderMove.bind(this);
    this.handlePanResponderRelease = this.handlePanResponderRelease.bind(this);

    this.panResponder = PanResponder.create({
      onStartShouldSetPanResponder: () => true,
      onMoveShouldSetPanResponder: () => true,
      onPanResponderMove: this.handlePanResponderMove,
      onPanResponderRelease: this.handlePanResponderRelease,
    });

    const location = props.history.location;
    this.state = {
      width: 0,
      height: 0,
      left: new Animated.Value(0),
      animationEnded: false,
      routes: getRoutePaths(props.children),
      anim: null,
      prevLocation: location,
      currentLocation: location,
      prevIndex: null,
      currentIndex: null,
      swipeFired: false,
    };
  }
  componentWillReceiveProps(nextProps) {
    const { anime: prevAnim, currentLocation: prevLocation } = this.state;
    const currentLocation = nextProps.history.location;
    const routes = getRoutePaths(nextProps.children);

    const prevIndex = routes.indexOf(prevLocation.pathname);
    const currentIndex = routes.indexOf(currentLocation.pathname);

    prevAnim && prevAnim.stop();
    const left = new Animated.Value(0);
    const anim = (() => {
      if (prevIndex === currentIndex) {
        return null;
      } else {
        return Animated.timing(left, {
          toValue: prevIndex > currentIndex ? -1 : 1,
          duration: 500,
          useNativeDriver: true,
        });
      }
    })();
    anim && anim.start(({ finished }) => finished && this.onAnimationEnd());
    this.setState({
      anim,
      left,
      prevLocation,
      currentLocation,
      prevIndex,
      currentIndex,
      animationEnded: false,
      routes,
    });
  }
  onLayout(evt) {
    const { width, height } = evt.nativeEvent.layout;
    this.setState({ width, height });
  }
  onAnimationEnd() {
    this.setState({ animationEnded: true });
  }
  handlePanResponderMove(_, gestureState) {
    const { dx, dy } = gestureState;
    const { currentIndex, routes, swipeFired } = this.state;

    if (swipeFired || dy < -50 || dy > 50) {
      return;
    }

    if (dx < -50 && currentIndex + 1 < routes.length) {
      this.props.history.replace(routes[currentIndex + 1]);
      this.setState({ swipeFired: true });
    } else if (dx > 50 && currentIndex > 0) {
      this.props.history.replace(routes[currentIndex - 1]);
      this.setState({ swipeFired: true });
    }
  }
  handlePanResponderRelease() {
    this.setState({ swipeFired: false });
  }
  render() {
    const { animationEnded, prevIndex, currentIndex, prevLocation, currentLocation } = this.state;
    const transform = [
      {
        translateX: this.state.left.interpolate({
          inputRange: [-1, 1],
          outputRange: [0, -this.state.width * 2],
        }),
      },
    ];

    const currentChildren = <Switch location={currentLocation}>{this.props.children}</Switch>;
    const prevChildren = <Switch location={prevLocation}>{this.props.children}</Switch>;
    const viewStyle = { width: this.state.width };

    return (
      <View style={{ flex: 1 }} onLayout={this.onLayout} {...this.panResponder.panHandlers}>
        <Animated.View
          style={{
            width: this.state.width * 3,
            height: this.state.height,
            flexDirection: 'row',
            transform,
          }}>
          {prevIndex != null && prevIndex > currentIndex ? (
            <View key={currentLocation.pathname} style={viewStyle}>
              {currentChildren}
            </View>
          ) : (
            <View style={viewStyle} />
          )}
          <View key={prevLocation.pathname} style={viewStyle}>
            {animationEnded || prevChildren}
          </View>
          {prevIndex != null && prevIndex < currentIndex ? (
            <View key={currentLocation.pathname} style={viewStyle}>
              {currentChildren}
            </View>
          ) : (
            <View style={viewStyle} />
          )}
        </Animated.View>
      </View>
    );
  }
}

export default withRouter(SwipeableRouteContainer);

ここに周辺の実装は公開しているので, 必要があればこちらも参照してください.

実装の要点

最上位のViewは必須

onLayout用に1階層余分なViewがいますが, このViewは現状 (0.56) 必須です.
ReactNativeのバグな気がしますが, AndroidでAnimated.Viewflexを指定して同様の実装をした場合にtransform周りがおかしな挙動になります.
これを回避する為に1階層余分にViewを配置しています.

表示要素のkey指定はComponentの再生成を防ぐ為

prevLocation.pathnameのようにパスをkeyに指定することで, 前回と今回のrender結果に同じパスが含まれる場合に, Componentの再生成ではなく, propsの更新になるようにしています.

translateXでのアニメーション

多少なりともパフォーマンス良くスワイプする為に, useNativeDriverを指定しています.
その関係でtranslateXで位置を指定しています. leftはNativeDriverに対応していません.
また, webで使う場合はuseNativeDriverfalseに指定する必要があるはずです. (他にも動作しない箇所がある可能性あり)

パス判定は完全一致でしか判定していないので, 要調整

react-routerにはパスマッチ条件の調整ができる機能があります. これに対応していないので, 実際に使う場合は調整が必要です.

完成図


(マウントタイミングがわかるようにログを表示していますが, 2つの出力が1回分です)

それっぽい感じになった気がします. やったー.

(Updated: )

comments powered by Disqus