react-router-nativeでタブナビゲーター+スワイプ対応をする
react-routerのネイティブ用実装であるreact-router-nativeでスワイプ対応のボトムタブナビゲーションを実装してみました.
navigationライブラリの選定
そもそもReactNativeのnavigation用のライブラリのいくつかはスワイプアニメーションに既に対応しています.
それらのライブラリを使わず, react-router-nativeを使った理由について, まずは説明します.
自分がナビゲーターに求める機能とその理由は以下の5点です.
- タブ, スタックに対応できる
- スワイプによる切り替えができる
- 別のアクションを入れる場合もあるが, UX的にスワイプ対応は必須だと思っているから
- スクリーンのマウントタイミングが切り替え時であること
componentDidMount
時にリスト要素の取得をする場合, 余計なViewの読み込みをされると困る
- UIのカスタマイズ性
- 特定のUIしか提供できない場合, そのライブラリを使い続けるのが厳しくなる局面が想定される為
- webにも対応している
- ReactNativeを触ったモチベーションの1つにweb, nativeのコードシェアリングがあるので, 共通化できると好印象
- ナビゲーション周りはwebと挙動が変わる可能性は高いので必須ではない
react-navigation
ReactNativeは公式でreact-navigationを推奨しています.
実際, 簡単にスタックやタブナビゲーターを簡単に作成でき, 派生ライブラリでスワイプアニメーションにも対応できます.
ただ, タブのカスタマイズ性はイマイチだなと感じました.
デフォルトの状態でLandscapeにするとこんな感じの見た目になります.
![](/assets/images/Simulator-Screen-Shot-iPhone-X-2018-08-17-at-15.12.04-300x139.png)
もう少し高さが欲しくて, 文字はアイコンの下が良いと思った場合に, 恐らくtabBarComponent
を指定することで調整は可能だと思われますが, props
の仕様はコードを読むしかなく (かつ追いづらい), この時点で候補から外しました.
また, webに対応してるようなことが書かれていますが, 対応していません.
対応するPRが一部出ていますが, これだけでは足りなさそうなので, webで使うのは現時点では避けたほうが無難です.
react-navigation-fluid-transitions, react-native-router-flux
FluidTransitionsとreact-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でのスワイプ実装
実行フロー
さて本題です. 設計自体は割とシンプルに以下の手順です.
これに加えてスワイプによる遷移のトリガーを追加すれば大丈夫そうです.
flex:1
でViewを置く- 1のViewの
onLayout
で横幅, 縦幅のサイズを取得する - 2で取得した横幅x3のViewを用意し, 表示位置を右/左に移動することでアニメーションを行う
![](/assets/images/react-router-swipe-300x234.png)
実装
/**
* @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.View
にflex
を指定して同様の実装をした場合にtransform
周りがおかしな挙動になります.
これを回避する為に1階層余分にViewを配置しています.
表示要素のkey指定はComponentの再生成を防ぐ為
prevLocation.pathname
のようにパスをkeyに指定することで, 前回と今回のrender結果に同じパスが含まれる場合に, Componentの再生成ではなく, propsの更新になるようにしています.
translateXでのアニメーション
多少なりともパフォーマンス良くスワイプする為に, useNativeDriver
を指定しています.
その関係でtranslateX
で位置を指定しています. left
はNativeDriverに対応していません.
また, webで使う場合はuseNativeDriver
をfalse
に指定する必要があるはずです. (他にも動作しない箇所がある可能性あり)
パス判定は完全一致でしか判定していないので, 要調整
react-routerにはパスマッチ条件の調整ができる機能があります. これに対応していないので, 実際に使う場合は調整が必要です.
完成図
![](/assets/images/react-router-native-swipe.gif)
(マウントタイミングがわかるようにログを表示していますが, 2つの出力が1回分です)
それっぽい感じになった気がします. やったー.
記事が気に入ったらチップを送ることができます!
You can give me a cup of coffee :)
Kyash ID: soranoba
Amazon: Wish List
GitHub Sponsor: github.com/sponsors/soranoba
PayPal.Me: paypal.me/soranoba
(Updated: )