1. はじめに
最近は本職は完全にSwiftでの開発がメインになりましたが、同時並行でReactNativeも色々と触っています。
前回はどんな感じでReactNativeの最初のとっかかり部分を攻略したかという点に関してざっくりとまとめました。
実際にある程度の勘所に関しては一応知識ベースではあれども一通り抑えられたのかなと思ったので、これまでQiitaで展開していた、Swiftで作成したUIサンプルの実装をReactNativeでも行ってみるようにしてみました。コードの共通化がある程度できることやサードパーティのライブラリが豊富な点はReactNativeの面白い部分ではあるんですが、いざUIを作成するとなると、
- iOS/Androidでの端末でのデザインの差異はどこまで実装するか?
- サードパーティーのライブラリをどのように活用すればよいか?
という点で結構悩んでしまったこともあり、今回は手前味噌ですができるだけiOS/Androidでもなるべく不自然ではないようなUIにできる限り整えるという部分に重きを置いて、簡単なサンプルを作成しましたのでそのサンプルに関する解説をまとめました。
今回の記事のポイントとなる部分を「React&React Native入門者の会 #2」にて発表する機会がありましたので、その際に使用したスライドもここに共有致します。
このスライド内では今回の記事に関する概要とポイントになる部分をまとめていますので、皆様のご理解の参考となれば幸いに思います。
※こちらのサンプルはPullRequestやIssue等はお気軽にどうぞ!
2. 本サンプルに関して
今回のサンプルは下記のキャプチャ画面のような形になります。特にデザインをする上で参考にしたアプリ等はありませんが、今回はなるべくiOS/Androidでのデザインの差異をできるだけ1つのコードで合わせられるようにNativeBaseというUIコンポーネントのライブラリを使用し、それをベースにUIを組み立ててその他UI表現や非同期処理が必要な部分にもいくつかライブラリを使用しています。
また、NativeBaseの導入に関しても、他のパッケージ同様にnpmコマンドで下記のようにインストールします。
$ npm install native-base --save
$ react-native link
ReactNativeはサードパーティー製のライブラリが結構充実している印象があったので、実際の使い勝手の検証も含めて色々と導入してみた形になります。
★2-1. サンプルのキャプチャ画面
サンプルのキャプチャ画像1:
サンプルのキャプチャ画像2:
★2-2. 使用しているライブラリの一覧とバージョン
今回のサンプルでのReact及びReactNativeのバージョンは下記の通りになります。
- ReactNative: Ver0.43.4
- React: Ver16.0.0-alpha.6
また今回のサンプルでNativeBase以外で使用している主なライブラリの一覧は下記のようになります。
ライブラリ名 | ライブラリの機能概要 | バージョン |
---|---|---|
axios | 非同期通信(GETのみ) | 0.16.0 |
react-native-router-flux | 画面遷移のコントロール | 3.38.0 |
react-native-scrollable-tab-view | タブ型のコンテンツ切り替え | 0.6.3 |
react-native-snap-carousel | カルーセル型の画像切り替え | 2.1.0 |
react-native-super-grid | UICollectionView型のGridView表示 | 1.0.2 |
react-native-timeline-listview | スケジュール型のコンテンツ表示 | 0.2.0 |
※サンプルではRedux不使用の為、ステートの管理はそれぞれのコンポーネント内で行っています。
※サンプルのpackage.json
の記載の中でreact
のバージョン設定の部分とreact-native-router-flux
に関してはバージョンを固定する為に^(キャレット)
を外しています。
★2-3. 今回使用したサンプルの画面設計図
こちらはおまけになりますが、今回のサンプル実装に関する設計図になります。
あまりReactNative特有の処理はほとんどないのでUIモックに近い形になってはいますが、ご参考になれば幸いです。
3. NativeBaseの基本概要について
こちらはiOS/Androidでの端末によるデザインの違いをよしなにすることができるUI Component Libraryになります。
このライブラリで提供されているコンポーネントに関しては、基本的にはReactNative純正のコンポーネントやサードパーティ製のUIライブラリを拡張して作られているので、ReactNative純正のコンポーネントとの併用はもちろん、スタイルをカスタマイズすることも可能ですし、それぞれの見た目についてもネイティブアプリにかなり近いデザインになっていることが大きな特徴になります。
また、NativeBaseでUIを実装していく上で特徴的な部分としては、<Container>
タグが起点になってその中で下記のような階層構造で展開して行くのが特徴です。
基点となるのは<Container>
タグになり、その中にそれぞれのタグ構造が展開されていく形になります。
下記のコード例では「ヘッダー・コンテンツ・フッター」のアプリコンテンツの雛形部分を表示するサンプルになりますが、<Container>
タグが起点となってヘッダー(<Header>
)・コンテンツ(<Content>
)・フッター(<Footer>
)のタグがあり、その中でさらにそれぞれの部分に中身を展開されていく構造になります。※コンテンツ表示部分に関しては、この場合は<Content>
タグが起点となる。
NativeBaseを活用してUIを構築する際には、それぞれのタグの階層構造が重要な意味合いを持つ(構造を間違えるとUIが崩れてしまう)ので、この部分はドキュメントを参考にしてタグの階層構造を確認しておくと良いかと思います。
また、<Button onPress={ () => Actions.SomeContents() } />
の様にNativeBaseのButtonタグは、ReactNativeのコンポーネントTouchableOpacity
とTouchableNativeFeedback
を拡張して作られているので、onPressでの押下時の処理に関しても、TouchableOpacity
とTouchableNativeFeedback
を使用した際と同様の書き方で押下時のアクションを定義することができます。
ざっくりとしたイメージとしては、「ReactNative版のTwitterBootstrap」のような感じでしょうか。
import React, { Component } from 'react';
//NativeBaseを使用したコンポーネントの呼び出し
import {
Container,
Header,
Title,
Content,
Footer,
Button,
Left,
Right,
Body,
Icon,
} from 'native-base';
//ヘッダー・コンテンツ・フッター表示の基本形
class Sample extends Component {
//見た目部分のレンダリングを行う
render() {
return (
<Container>
{/* ヘッダー表示部分 */}
<Header>
<Left>
<Button transparent onPress={ () => console.log("MenuButtonTapped!") }>
<Icon name='menu' />
</Button>
</Left>
<Body>
<Title>Header</Title>
</Body>
<Right />
</Header>
{/* コンテンツ表示部分 */}
<Content>
ここにコンテンツを色々表示する形になります。
</Content>
{/* フッター表示部分 */}
<Footer>
<Button active>
<Icon ios='ios-paper' android="md-paper" />
<Text style={styles.footerTextStyle}>フィード</Text>
</Button>
<Button>
<Icon ios='ios-bookmark' android="md-bookmark" />
<Text style={styles.footerTextStyle}>お気に入り</Text>
</Button>
<Button>
<Icon ios='ios-map' android="md-map" />
<Text style={styles.footerTextStyle}>地図</Text>
</Button>
<Button badge>
<Badge><Text>20+</Text></Badge>
<Icon ios='ios-mail' android="md-mail" />
<Text style={styles.footerTextStyle}>メール</Text>
</Button>
</Footer>
</Container>
);
}
}
//このコンポーネントをインポート可能にする
export default Sample;
NativeBaseを活用してUIを作成していく上でポイントとなるのは、レイアウトの大枠部分やiOS/Android間で端末の差異が出てしまう部分に関してはできるだけNativeBaseでの実装に則ってレイアウトをしたりNativeBaseのコンポーネントを有効活用していくような方針で進めていき、サードパーティ製のUIライブラリが絡んでくる部分やデザイン部分の微調整が必要な部分については、ReactNative純正のコンポーネントとうまく組み合わせたりスタイルの設定を工夫する必要がある部分に関しては、独自に実装する様にミックスして使って行く方が良いかもしれません。
UIに関してはiOS/Androidと端末が違ってくるとその設計思想は異なってくるので、この部分についてはReactNativeをはじめとすつクロスプラットフォームを用いる上でも頭を悩ませてしまう部分かと思います。
- iOS → Human interface guideline
- Android → Google Design (Material Design)
のようにUIを作成・設計する上でのガイドラインが異なります。
特に「ヘッダー / アイコン / UITableView(iOS)/ListView(Android) / ドロワーメニュー / プリローダー / ラジオボタン」等の端末特有のデザイン面の考慮は自前で実装すると工数的にもかなり負担になる部分なので、このように端末間のデザイン差異をある程度吸収してくれる点やそれぞれのパーツに関してもネイティブアプリに近しいデザインになっている面は嬉しいので、この部分は実際にアプリを作って行く際にも是非とも深堀りして使いこなしていきたいと思います。
この部分を考えるきっかけになった記事:
端末による違いは避けては通れない宿命ではあるが、良いアプリにするためにはしっかりと向き合っていかなければいけない部分だと改めて思いました。
4. 今回の実装でNativeBaseの活用した部分に関する解説
本サンプルで使用している部分に関してNativeBaseのUI実装を活用して実装した部分に関して6点ほどピックアップしてみました。
iOS/Android間でデザインの差異が気になってしまう部分や、レイアウトの実装で便利そうに感じた部分が中心になっています。
★4-1. ヘッダー部分の実装に関するポイント
ヘッダー部分の実装に関してはiOS/Android共にネイティブアプリに近しいデザインにあらかじめ設定されているので、コンテンツでなるべく使いまわせるように共通化しています。
<CommonHeader>
タグの実装に関しては、それぞれのコンテンツを表示をしているコンポーネントで変わる部分になる、「アイコン・タイトル・遷移時のアクション」を引数にとる形の設計にしている部分がポイントになります。
※画面遷移のアクションについてはreact-native-router-flux
のActions
を利用。
またiOS用のステータスバーの設定に関する部分についてはiosBarStyle="light-content"
として白色で表示するという考慮も<Header>
タグの中ですでに考慮されているので便利です。
下記のコンポーネントに関しての呼び出し先での実装例についての例をピックアップすると、ドロワーメニューに応じて内容が変化するBaseContents.js
内では、
<CommonHeader title={this._onTitleSelected(this.state.itemSelected)} icon={"menu"} onPress={ () => this.openDrawer() } />`
のような形で記載をしています。ヘッダーをはじめとするどの画面でも使用し、かつ遷移のポイントになるようなコンポーネントに関してはなるべく共通化して使いまわせるように実装段階で考慮をしておくと便利かもしれません。
/**
* 共通ヘッダーのUI部分を表示するコンポーネント
*/
import React from 'react';
//ReactNativeを使用したコンポーネントの呼び出し
import {
StyleSheet,
} from 'react-native';
//NativeBaseを使用したコンポーネントの呼び出し
import {
Header,
Left,
Right,
Body,
Title,
Button,
Icon,
} from 'native-base';
//ヘッダー用のベースコンポーネントの内容を定義する
const CommonHeader = ({ title, icon, onPress }) => {
//表示する要素を返す
return (
<Header iosBarStyle="light-content" style={styles.headerBackStyle} hasTabs>
<Left>
<Button transparent onPress={onPress}>
<Icon style={styles.backStyle} name={icon || "arrow-back"} />
</Button>
</Left>
<Body>
<Title style={styles.titleStyle}>{title}</Title>
</Body>
<Right />
</Header>
);
};
//このコンポーネントのStyle定義
const styles = {
headerBackStyle: {
backgroundColor: '#000',
},
backStyle: {
color: '#fff',
},
titleStyle: {
color: '#fff',
},
};
//このコンポーネントをインポート可能にする
export default CommonHeader;
※補足事項:
今回は画面遷移にreact-native-router-flux
を使用しており、このライブラリを使用するとデフォルトではヘッダーのナビゲーションが付与されますが、今回はこの部分を使わないようにする為にhideNavBar={true}
としています。
/**
* アプリコンテンツ用のコンポーネント
*/
import React, { Component } from 'react';
//react-native-router-fluxのインポート宣言(Actionを使用)
import { Router, Scene } from 'react-native-router-flux';
//自作コンポーネント
import BaseContents from './components/BaseContents';
import ShopDetailContents from './components/ShopDetailContents';
import PhotoGalleryContents from './components/PhotoGalleryContents';
//コンポーネントの内容を定義する ※ ClassComponent
class App extends Component {
//各種ルーティングに対応するコンポーネントの内容をレンダリングする
/**
* Memo:
* モーダルのような遷移をする場合は「direction="vertical"」を属性に設定する。
*/
render() {
return (
<Router>
<Scene key="root">
<Scene key="BaseContents" initial={true} component={BaseContents} hideNavBar={true} />
<Scene key="ShopDetailContents" component={ShopDetailContents} hideNavBar={true} />
<Scene key="PhotoGalleryContents" component={PhotoGalleryContents} hideNavBar={true} />
</Scene>
</Router>
);
}
}
export default App;
ヘッダー部分は特にiOS/Androidでの端末間デザインの違いが最も現れやすい部分でもあるので、このような形で一つのコンポーネントとして管理できるのは便利ですね。
★4-2. 画像部分のグリッドレイアウト部分の実装に関するポイント
グリッドレイアウトに関しては、NativeBaseでは「react-native-easy-grid」というライブラリが使用できるようになります。
下記のコード例では3つの画像をタイル状に並べてサイズを整えて表示させるサンプルになりますが、react-native-easy-grid
で提供されている<Grid>
,<Col>
,<Row>
を用いて行う形になります。
(グリッドレイアウトに関する様々な設定方法がGithubリポジトリのREADMEにレイアウト例が掲載されているので是非確認をして見て下さい。)
グリッド状のレイアウトを作成したい際には実装がシンプルにできる部分ではあるかと思います。
/**
* お店の詳細を表示するコンポーネント
*/
//・・・(省略)・・・
//react-native-easy-gridのコンポーネントの呼び出し
import {
Col,
Row,
Grid
} from 'react-native-easy-grid';
//デバイスのサイズ取得
const {
width: DEVICE_WIDTH,
height: DEVICE_HEIGHT
} = Dimensions.get('window');
//ギャラリーの幅と高さの設定
const mainAreaWidth = DEVICE_WIDTH - 120;
const mainAreaHeight = 120 * 2;
const subAreaRect = 120;
//・・・(省略)・・・
//コンポーネントの内容を定義する ※ ClassComponent
class ShopInfo extends Component {
//コンポーネントの内容をレンダリングする
/**
* Memo:
*
*/
render() {
return (
<Container>
<ScrollView>
<Content>
{/* ・・・(省略)・・・ */}
{/* 2. 画像表示エリア */}
<View>
<Content>
<Grid>
<Col style={styles.firstImageStyle}>
<Image style={styles.firstImageStyle} source={require('../../assets/detail1.jpg')} />
</Col>
<Col>
<Row style={styles.secondImageStyle}>
<Image style={styles.secondImageStyle} source={require('../../assets/detail2.jpg')} />
</Row>
<Row style={styles.thirdImageStyle}>
<Image style={styles.thirdImageStyle} source={require('../../assets/detail3.jpg')} />
</Row>
</Col>
</Grid>
</Content>
</View>
{/* ・・・(省略)・・・ */}
</Content>
</ScrollView>
</Container>
);
}
}
//このコンポーネントのスタイル設定
const styles = {
/* ・・・(省略)・・・ */
firstImageStyle: {
width: mainAreaWidth,
height: mainAreaHeight,
},
secondImageStyle: {
width: subAreaRect,
height: subAreaRect,
},
thirdImageStyle: {
width: subAreaRect,
height: subAreaRect,
},
};
//このコンポーネントをインポート可能にする
export default ShopInfo;
写真やサムネイル画像表示のデザインはアプリのUI表現でも色々と工夫を凝らしたくなる部分ではありますが、なるべくシンプルにしたいと感じることは多くあるかと思います。このように見た目部分に加えてUIを作成する上で便利なライブラリが使えるという点もNativeBaseを使うメリットの一つのように思います。
★4-3. メニュー用のドロワー部分の実装に関するポイント
メニュー用のドロワー部分の関しては、NativeBaseでは「react-native-drawer」というライブラリが使用できるようになります。
本サンプルでは、下記のような設計でコンテンツの表示を行なっています。実装のポイントとしては、「メインのコンテンツを表示するコンポーネント」 と 「ドロワー表示を行うサイドメニュー用のコンポーネント」 を用意してドロワー表示用の<Drawer>
タグと組み合わせて実装を行う形となることです。
-
BaseContents.js
→ メニューボタンで表示するコンテンツの切り替えが行えるように現在表示の状態をステートで管理しておき、<Drawer>
タグ内に表示部分のコンポーネントを定義する -
SideContents.js
→ メニューを閉じるメソッドをPropsに引き渡し、メニューボタンを押下するとBaseContents.js
のステートを変更する
メニュー開閉部分の開き方については、iOS/Androidで開き方を変えていますが、その他<Drawer>
にタグに関する設定についてはreact-native-drawer
の設定でドロワーのサイズや開き方などの詳細を調整することもできるのでこちらも同様にREADMEを参考にしてパラメーターの調整を行うことで色々と表現にこだわることができるかと思います。
★BaseContents.js(コンテンツ表示部分):
/**
* ベースコンテンツ用のコンポーネント
*/
/* ・・・(省略)・・・ */
//NativeBaseを使用したコンポーネントの呼び出し
import {
Container,
Drawer,
Header,
Left,
Right,
Title,
Body,
Button,
Icon
} from 'native-base';
//ヘッダー用コンポーネントの宣言
import CommonHeader from './common/CommonHeader';
//ドロワー用コンポーネントの宣言
import SideContents from './SideContents';
/* ・・・(省略)・・・ */
//コンポーネントの内容を定義する ※ ClassComponent
class BaseContents extends Component {
//コンストラクタ
constructor(props) {
super(props);
//ステートの初期化を行う
this.state = { drawerOpen: false, drawerDisabled: false, itemSelected: 'ShopList' };
}
//ドロワーメニューを閉じる際に関する設定をする
closeDrawer = (item) => {
this.setState({itemSelected: item})
this._drawer._root.close()
};
//ドロワーメニューを開く際に関する設定をする
openDrawer = () => {
this._drawer._root.open()
};
//ドロワーメニューに対応したシーンの切り替えをする
_onItemSelected = (selected) => {
switch (selected) {
case "ShopList":
return <ShopList />
case "ColumnList":
return <ColumnList />
case "MyPurchase":
return <PurchaseHistory />
case "GithubLink":
return <WebView source={{uri: 'https://github.com/fumiyasac/LikeCustomTransition'}} />
case "SlideshareLink":
return <WebView source={{uri: 'https://www.slideshare.net/fumiyasakai37/nativebaseui'}} />
default:
return <ShopList />
}
};
//ドロワーメニューに対応したタイトルの切り替えをする
_onTitleSelected = (selected) => {
switch (selected) {
case "ShopList":
return "紹介お店一覧"
case "ColumnList":
return "コラム一覧"
case "MyPurchase":
return "Myお買い物"
case "GithubLink":
return "リポジトリ"
case "SlideshareLink":
return "スライド"
default:
return "紹介お店一覧"
}
};
//コンポーネントの内容をレンダリングする
/**
* Memo:
* NativeBaseのDrawerは下記のライブラリを拡張して作られている
* (各種プロパティの参考) React Native Drawer
* https://github.com/root-two/react-native-drawer#props
*/
render() {
return (
<Drawer
ref={ (ref) => this._drawer = ref }
type={(Platform.OS === 'ios') ? "static" : "overlay"}
content={
<SideContents closeDrawer={this.closeDrawer} />
}
onOpen={ () => {
this.setState({drawerOpen: true})
}}
onClose={ () => {
this.setState({drawerOpen: false})
}}
tweenHandler={ (ratio) => {
return {
mainOverlay: { opacity: ratio / 2, backgroundColor: 'black' }
}
}}
captureGestures={true}
tweenDuration={200}
disabled={this.state.drawerDisabled}
openDrawerOffset={ (viewport) => {
return 80
}}
side={"left"}
closedDrawerOffset={ () => 0 }
panOpenMask={0.04}
negotiatePan={true}
>
<CommonHeader title={this._onTitleSelected(this.state.itemSelected)} icon={"menu"} onPress={ () => this.openDrawer() } />
<Container>
{this._onItemSelected(this.state.itemSelected)}
</Container>
</Drawer>
);
}
}
/* ・・・(省略)・・・ */
export default BaseContents;
★SideContents.js(ドロワー表示部分):
/**
* サイドコンテンツ用のコンポーネント
*/
import React, {
Component,
PropTypes
} from 'react';
//ReactNativeを使用したコンポーネントの呼び出し
import {
StyleSheet,
View,
Image,
Text,
Dimensions
} from 'react-native';
//NativeBaseを使用したコンポーネントの呼び出し
import {
Container,
Button,
Content,
ListItem,
Separator,
Icon
} from 'native-base';
//幅と高さを取得する
const {
width: DEVICE_WIDTH,
height: DEVICE_HEIGHT
} = Dimensions.get('window');
//コンポーネントの内容を定義する ※ ClassComponent
class SideContents extends Component {
//このコンポーネントのpropTypesの定義(this.propsで受け取れる情報に関するもの)
static propTypes = {
closeDrawer: PropTypes.func.isRequired,
};
//コンポーネントの内容をレンダリングする
/**
* Memo: アイコン選定の参考はこちら
* https://github.com/oblador/react-native-vector-icons
* https://oblador.github.io/react-native-vector-icons/
*/
render() {
return (
<Container style={styles.containerBackgroundStyle}>
<View style={styles.containerHeaderStyle}>
<Image style={styles.containerHeaderImageStyle} source={require('../assets/otsuka_sample.jpg')} />
<View style={styles.overlayStyle}>
<Text style={styles.overlayTextStyle}>大塚Deお買い物Menu</Text>
</View>
</View>
<Content>
{/* ドロワーメニューでのメニュー部分(コンポーネント表示切り替え) */}
<Separator bordered>
<Text>コンテンツ</Text>
</Separator>
<ListItem onPress={ () => {this.props.closeDrawer("ShopList")} }>
<Icon ios='ios-pizza' android="md-pizza" style={{color: '#ffc125'}}/>
<Text style={styles.menuTextStyle}>紹介お店一覧</Text>
</ListItem>
<ListItem onPress={ () => {this.props.closeDrawer("ColumnList")} }>
<Icon ios='ios-book' android="ios-book" style={{color: '#ff6600'}}/>
<Text style={styles.menuTextStyle}>コラム一覧</Text>
</ListItem>
<ListItem onPress={ () => {this.props.closeDrawer("MyPurchase")} } last>
<Icon ios='ios-cart' android="md-cart" style={{color: '#ff3333'}}/>
<Text style={styles.menuTextStyle}>Myお買い物</Text>
</ListItem>
{/* ドロワーメニューでのメニュー部分(WebViewでの表示) */}
<Separator bordered>
<Text>このサンプルに関して</Text>
</Separator>
<ListItem onPress={ () => {this.props.closeDrawer("GithubLink")} }>
<Icon ios='logo-octocat' android="logo-octocat" style={{color: '#333333'}}/>
<Text style={styles.menuTextStyle}>Githubへのリンク</Text>
</ListItem>
<ListItem onPress={ () => {this.props.closeDrawer("SlideshareLink")} }>
<Icon ios='logo-linkedin' android="logo-linkedin" style={{color: '#0077b5'}}/>
<Text style={styles.menuTextStyle}>SlideShareへのリンク</Text>
</ListItem>
</Content>
</Container>
);
}
}
/* ・・・(省略)・・・ */
//インポート可能にする宣言
export default SideContents;
ドロワーメニューの実装もネイティブアプリ同様に、自前での実装を行おうとすると結構大変な実装の一つなので(iOSのネイティブアプリでDIYしたことがあったが確かにしんどかった記憶が...)このように便利なライブラリでの実装が考慮されている点はありがたいですね。
★4-4. メニューで使用しているアイコンの実装に関するポイント
アイコンに関しても、NativeBaseでは「react-native-vector-icons」というライブラリが使用できるようになるのでiOS/Androidで両方対応したアイコンを使用することも可能です。
またアイコンの種類に関しては下記のリンクを参考にしてみて下さい。(※ものによっては表示されないものもあったりするのでご注意下さい)
例えば、下記のような書き方でiOS/Android両方対応しているアイコンを設定する際には、NativeBaseの<Icon>
タグと併用して下記のような書き方ができます。
※下記はピザのアイコンを設定する際の例になります。
<Icon ios='ios-pizza' android="md-pizza" style={{color: '#ffc125'}}/>
アイコンもヘッダー同様に端末間デザインの違いが現れやすい部分ですし、このように統一を図りやすい考慮があらかじめなされているのは実際に実装を行なった際に便利さを感じた部分でもありました。
★4-5. データ表示用のリストアイテム部分の実装に関するポイント
リストアイテムはアプリUIでは欠かせない部分ですし、使わない局面はないくらいに重要な部分になるかと思います。
特にNativeBaseでは<ListItem>
タグとタグの階層構造を指定するだけで、ある程度決まった形のレイアウトであれば下記のように簡単に実現できるようになっています。
この部分ではアバター付きのコメント表示に関する部分になりますが、<ListItem>
タグを用いることによってシンプルに記述することができます。ただ、リスト表記の部分についてはスタイルの調整が出やすい部分なので、自前で実装しなければならない場合はスタイルの設定は注意が必要かもしれません。
/**
* コメント表示用UIのベース部分を表示するコンポーネント
*/
import React from 'react';
//ReactNativeを使用したコンポーネントの呼び出し
import {
StyleSheet,
} from 'react-native';
//NativeBaseを使用したコンポーネントの呼び出し
import {
ListItem,
Left,
Body,
Right,
Text,
Thumbnail
} from 'native-base';
//一覧シンプルリスト表示用のベースコンポーネントの内容を定義する
const CommonComment = ({ comment }) => {
//取得した引数(オブジェクト:{ comment })を分割する
const { id, title, description, image_url, time } = comment;
//表示する要素を返す
return (
<ListItem avatar>
<Left>
<Thumbnail source={image_url} />
</Left>
<Body>
<Text>{title}</Text>
<Text style={styles.commentTextStyle} note>{description}</Text>
</Body>
<Right>
<Text note>{time}</Text>
</Right>
</ListItem>
);
};
//このコンポーネントのスタイル設定
const styles = {
commentTextStyle: {
marginTop: 8,
fontSize: 14,
lineHeight: 18
},
};
//このコンポーネントをインポート可能にする
export default CommonComment;
上記のコードでもわかるように、ある程度決まった形であればスタイルの記述を少なくシンプルにすることもできたり、様々な見た目に関する考慮もされているのでここも色々と私自身も試して見たくなる部分ですね。
★4-6. APIからのデータを表示するカード型コンテンツの実装に関するポイント
お店の一覧を表示する部分に関しては下記のような形で、非同期処理で取得したデータをステートに格納し、ステートの変化を検知した際に実行される処理の際に取得したデータをカード状の見た目を作るコンポーネントに当てはめて表示するような形にしています。
また、カード状の見た目に関する部分に関しては<Card>
タグを用いて共通化したコンポーネントを作成して対応する形になっています。
全体的な処理をまとめると下記のような形になります。
★コンテンツ表示部分(ShopList.js):
/**
* お店一覧を表示するコンポーネント
*/
import React, {
Component
} from 'react';
//ReactNativeを使用したコンポーネントの呼び出し
import {
ScrollView,
View,
Text,
} from 'react-native';
//NativeBaseを使用したコンポーネントの呼び出し
import {
Spinner,
Button,
} from 'native-base';
//アルバム詳細用の共通コンポーネントのインポート宣言
import CommonCard from '../common/CommonCard';
//react-native-router-fluxのインポート宣言(Actionを使用)
import { Actions } from 'react-native-router-flux';
//HTTP通信用のライブラリ'axios'のインポート宣言
import axios from 'axios';
//コンポーネントの内容を定義する ※ ClassComponent
class ShopList extends Component {
//コンストラクタ
constructor(props) {
super(props);
//ステートの初期化を行う
this.state = { shops: [], isLoading: true, isError: false, modalVisible: false };
}
//ショップデータをフェッチする
fetchShopData() {
//Memo: 自作APIとバインドする(ここはRails4.1.7で構築)
axios.get('https://rocky-dusk-33235.herokuapp.com/shops.json')
.then(response => this.setState({ shops: response.data.shops.contents, isLoading: false }))
.catch(error => this.setState({ shops: [], isLoading: false, isError: true }));
}
//ショップデータの再読み込みを行う
reloadShops() {
this.state = { shops: [], isLoading: true, isError: false };
this.fetchShopData();
}
//ショップデータのレンダリングを行う
renderShops() {
return this.state.shops.map(shop =>
<CommonCard key={shop.id} shop={shop} onPress={ () => Actions.ShopDetailContents({id: shop.id, title: "お店詳細"}) } />
);
}
//コンポーネントの内容がMountされる前に行う処理
componentWillMount() {
this.fetchShopData();
}
//コンポーネントの内容をレンダリングする
render() {
//ローデーィング時
if (this.state.isLoading) {
return (
<View style={styles.spinnerWrapperStyle}>
<Spinner color="#666" />
<Text style={styles.spinnerInnerText}>データ取得中...</Text>
</View>
);
}
//エラー発生時
if (this.state.isError) {
return (
<View style={styles.spinnerWrapperStyle}>
<Text style={styles.spinnerInnerTextStyle}>エラー:データを取得できませんでした。</Text>
<View>
<Button style={styles.buttonStyle} onPress={ () => this.reloadShops() } dark>
<Text style={styles.buttonTextStyle}>再度データを取得する</Text>
</Button>
</View>
</View>
);
}
//正常処理時
return (
<ScrollView style={styles.backgroundContainer}>
{this.renderShops()}
</ScrollView>
);
}
}
//このコンポーネントのスタイル設定
const styles = {
backgroundContainer: {
backgroundColor: '#fff',
},
spinnerWrapperStyle: {
flex: 1,
backgroundColor: '#fff',
justifyContent: 'center',
alignItems: 'center',
},
spinnerInnerTextStyle: {
fontSize: 13,
textAlign: 'center',
color: '#666',
},
buttonStyle: {
marginTop: 10,
alignItems: 'center',
},
buttonTextStyle: {
fontSize: 13,
fontWeight: 'bold',
textAlign: 'center',
color: '#fff',
},
};
//インポート可能にする宣言
export default ShopList;
★コンテンツ表示部分(CommonCard.js):
/**
* カード型のUIのベース部分を表示するコンポーネント
*/
import React from 'react';
//ReactNativeを使用したコンポーネントの呼び出し
import {
StyleSheet,
Image
} from 'react-native';
//NativeBaseを使用したコンポーネントの呼び出し
import {
Content,
Card,
CardItem,
Left,
Body,
Text,
Button,
Icon,
Thumbnail
} from 'native-base';
//一覧情報表示用のベースコンポーネントの内容を定義する
const CommonCard = ({ shop, onPress }) => {
//取得した引数(オブジェクト:{ shop })を分割する
const { id, title, category, kcpy, detail, image_url } = shop;
//表示する要素を返す
return (
<Content>
<Card>
<CardItem>
<Body>
<Text>{title}</Text>
<Text style={styles.noteStyle} note>カテゴリー:{category}</Text>
<Text note>{kcpy}</Text>
</Body>
</CardItem>
<CardItem cardBody>
<Image style={styles.imageStyle} source={{ uri: image_url }} />
</CardItem>
<CardItem content>
<Text style={styles.cardContentTextStyle}>{detail}</Text>
</CardItem>
<CardItem style={styles.cardBottomStyle}>
<Text style={styles.cardBottomTextStyle}>更新日:2017/04/17</Text>
<Button onPress={onPress} transparent>
<Icon active name="chatbubbles" />
<Text style={styles.cardBottomTextStyle}>詳細情報を見る</Text>
</Button>
</CardItem>
</Card>
</Content>
);
};
//このコンポーネントのStyle定義
const styles = {
noteStyle: {
marginTop: 5,
},
imageStyle: {
marginLeft: 15,
marginRight: 15,
height: 240,
flex: 1
},
cardContentTextStyle: {
fontSize: 14,
lineHeight: 22
},
cardBottomStyle: {
justifyContent: 'space-around'
},
cardBottomTextStyle: {
fontSize: 13
},
};
//このコンポーネントをインポート可能にする
export default CommonCard;
この他にも、テキストフィールドやフッター・ラジオボタン等UI実装の際に必要なパーツ類が色々用意されており、UIの開発をする上では色々重宝しそうな感じではあります。ただドキュメントだけではNativeBaseで作られているものが、ReactNativeのどのコンポーネントを使って拡張をしているかがちょっとわかりにくいので、下記のGithubのファイルを辿って中の実装まで確認してみるとよりわかるかと思います。
★4-7. その他UIライブラリでの実装箇所に関する解説
NativeBaseでの実装を中心に解説を行なってきましたが、今回はUI表現のためのライブラリも何点か実装しましたので、その部分に関しても軽く解説します。
今回のデータ表示部分に関してはsrc/SampleDataStub.js
にスタブとなるデータを定義したファイルがあり、そのデータを読み込んでいる形になります。
その1(左右のスワイプで画像のスライド表現をする):
- 使用ライブラリ:react-native-snap-carousel
左右にスワイプをすることで画像をスライドショーの様に切り替えることができるUIを提供するライブラリになります。
このサンプルではライブラリで提供されている<Carousel>
タグの中にスライド表示用のコンテンツを表示させるような形にしています。
まとめると下記のような形になります。
/**
* コラム一覧ページを表示するコンポーネント
*/
import React, {
Component
} from 'react';
//ReactNativeを使用したコンポーネントの呼び出し
import {
ScrollView,
View,
Dimensions
} from 'react-native';
//NativeBaseを使用したコンポーネントの呼び出し
import {
Container,
Content,
Separator,
Text,
} from 'native-base';
//react-native-router-fluxのインポート宣言(Actionを使用)
import { Actions } from 'react-native-router-flux';
//react-native-snap-carouselのインポート宣言
import Carousel from 'react-native-snap-carousel';
//コラム一覧表示の共通コンポーネントのインポート宣言
import CommonSliderItem from '../common/CommonSliderItem';
import CommonColumnListItem from '../common/CommonColumnListItem';
//表示データの読み込み
import { getSliderList, getArchiveList } from '../../stub/SampleDataStub';
//デバイスのサイズ取得
const {
width: DEVICE_WIDTH,
} = Dimensions.get('window');
//ギャラリーの幅と高さの設定
const sliderWidth = DEVICE_WIDTH;
const sliderHeight = DEVICE_WIDTH / 2;
//コンポーネントの内容を定義する ※ ClassComponent
class ColumnList extends Component {
//スライド用のコンポーネントを組み立てる処理
_getSlides() {
return getSliderList().map((slider, index) => {
return (
<CommonSliderItem key={index} slider={slider} onPress={ () => console.log("_getSlides() : Work In Progress.") } />
);
});
}
//リスト用のコンポーネントを組み立てる処理
_getArchives() {
return getArchiveList().map((archive, index) => {
return (
<CommonColumnListItem key={index} archive={archive} onPress={ () => console.log("_getArchives() : Work In Progress.") } />
);
});
}
//コンポーネントの内容をレンダリングする
render() {
return (
<Container style={styles.backgroundContainer}>
{/* 1. スライドメニュー */}
<View style={styles.containerWrappedViewStyle}>
<Carousel
ref={(carousel) => { this._carousel = carousel; }}
sliderWidth={sliderWidth}
itemWidth={sliderWidth}
firstItem={0}
inactiveSlideScale={0.86}
inactiveSlideOpacity={0.38}
enableMomentum={false}
containerCustomStyle={styles.containerCustomStyle}
contentContainerCustomStyle={styles.contentContainerCustomStyle}
showsHorizontalScrollIndicator={false}
snapOnAndroid={true}
>
{/* スライド用のコンテンツを表示する */}
{this._getSlides()}
</Carousel>
</View>
{/* 2. アーカイブ部分 */}
<ScrollView>
<Content>
<Separator bordered>
<Text>過去のアーカイブ</Text>
</Separator>
{/* アーカイブ用のコンテンツを表示する */}
{this._getArchives()}
</Content>
<Separator bordered>
<Text>コラムについて</Text>
</Separator>
<Text style={styles.backgroundDescription}>
歴史がありながらも最近の発展やまちづくりにも目覚ましい下町情緒が溢れるあたたかな街、大塚。
オフィス街・地域のお祭り・ライブハウス・隠れ家的な名店等、様々な表情をこの街は見せてくれます。
</Text>
</ScrollView>
</Container>
);
}
}
//このコンポーネントのスタイル設定
const styles = {
backgroundContainer: {
backgroundColor: '#fff',
},
backgroundDescription: {
color: '#666',
textAlign: 'left',
fontSize: 14,
marginTop: 16,
marginLeft: 16,
marginRight: 16,
marginBottom: 16,
lineHeight: 20,
},
buttonTextStyle: {
fontSize: 13,
fontWeight: 'bold',
textAlign: 'center',
color: '#fff',
},
containerWrappedViewStyle: {
height: sliderHeight,
},
contentContainerCustomStyle: {
height: sliderHeight,
},
};
//インポート可能にする宣言
export default ColumnList;
その2(UICollectionViewの様な表現をする):
- 使用ライブラリ:react-native-super-grid
こちらはiOSでいうところの「UICollectionView」できるUIを提供するライブラリになります。
このサンプルでは、ライブラリで提供されている<GridView>
タグのrenderItem
の部分にイメージ画像とキャプションに関するコンポーネントを設定し、items
の部分に表示するデータを設定します。
まとめると下記のような形になります。
/**
* フォトギャラリー画面を表示するコンポーネント
*/
import React, {
Component
} from 'react';
//ReactNativeを使用したコンポーネントの呼び出し
import {
StyleSheet,
View,
Image,
Dimensions
} from 'react-native';
//NativeBaseを使用したコンポーネントの呼び出し
import {
Container,
Content,
Header,
Text
} from 'native-base';
//幅と高さを取得する
const {
width: DEVICE_WIDTH
} = Dimensions.get('window');
//グリッドの幅調整用の値
const gridWidth = DEVICE_WIDTH / 2 - 15;
//共通ヘッダーのインポート宣言
import CommonHeader from './common/CommonHeader';
//グリッド表示用のライブラリのインポート宣言(NativeBaseのグリッドを使わない)
import GridView from 'react-native-super-grid';
//表示データの読み込み
import { getGalleryList } from '../stub/SampleDataStub';
//react-native-router-fluxのインポート宣言(Actionを使用)
import { Actions } from 'react-native-router-flux';
//コンポーネントの内容を定義する ※ ClassComponent
class PhotoGalleryContents extends Component {
//コンポーネントの内容をレンダリングする
/**
* Memo:
*
*/
render() {
//コンポーネントのレンダリング
return (
<Container>
<CommonHeader title={"フォト一覧"} onPress={ () => Actions.pop() } />
<Content>
{/* iOSでのUICollectionViewの様なレイアウト */}
<GridView
itemWidth={130}
items={getGalleryList()}
style={styles.gridViewStyle}
renderItem={ item => (
<Image style={styles.itemContainerStyle} source={{ uri: item.gallery }}>
<Text style={styles.itemNameStyle}>{item.name}</Text>
<Text style={styles.itemDateStyle}>{item.date}</Text>
</Image>
) }
/>
</Content>
</Container>
);
}
}
//このコンポーネントのスタイル設定
const styles = {
gridViewStyle: {
paddingTop: 10,
flex: 1,
},
itemContainerStyle: {
justifyContent: 'flex-end',
borderRadius: 5,
padding: 10,
height: 150,
width: gridWidth,
},
itemNameStyle: {
fontSize: 16,
color: '#fff',
fontWeight: '600',
},
itemDateStyle: {
fontWeight: '600',
fontSize: 12,
color: '#fff',
},
};
//このコンポーネントをインポート可能にする
export default PhotoGalleryContents;
その3(年表やタイムラインの様な表現をする):
- 使用ライブラリ:react-native-timeline-listview
年表やタイムラインの様な表示ができるUIを提供するライブラリになります。
このサンプルでは、ライブラリで提供されている<Timeline>
タグのdata
の部分に表示するデータを設定します。
まとめると下記のような形になります。
/**
* お買い物履歴を表示するコンポーネント
*/
import React, {
Component
} from 'react';
//ReactNativeを使用したコンポーネントの呼び出し
import {
StyleSheet,
} from 'react-native';
//NativeBaseを使用したコンポーネントの呼び出し
import {
Container,
Content,
} from 'native-base';
//react-native-router-fluxのインポート宣言(Actionを使用)
import { Actions } from 'react-native-router-flux';
//react-native-timeline-listviewのインポート宣言
import Timeline from 'react-native-timeline-listview';
//表示データの読み込み
import { getPurchaseHistory } from '../../stub/SampleDataStub';
//コンポーネントの内容を定義する ※ ClassComponent
class PurchaseHistory extends Component {
//コンポーネントの内容をレンダリングする
/**
* Memo:
*
*/
render() {
//コンポーネントのレンダリング
return (
<Container style={styles.backgroundContainer}>
<Content style={styles.backgroundDescription}>
<Timeline
innerCircle={'dot'}
timeStyle={styles.timeStyle}
descriptionStyle={styles.descriptionStyle}
data={getPurchaseHistory()}
/>
</Content>
</Container>
);
}
}
//このコンポーネントのスタイル設定
const styles = {
backgroundContainer: {
backgroundColor: '#fff',
},
backgroundDescription: {
paddingTop: 16,
paddingLeft: 16,
paddingRight: 16,
paddingBottom: 16,
},
timeStyle: {
textAlign: 'center',
backgroundColor: '#999',
fontSize: 12,
fontWeight: 'bold',
color: '#fff',
padding: 5,
},
descriptionStyle: {
color:'#666',
lineHeight: 18,
}
};
//このコンポーネントをインポート可能にする
export default PurchaseHistory;
また今回のUIライブラリの選定に関しては下記の記事等を参考にして、色々試しながら導入した感じになります。
5. NativeBaseでスナップショットテストをしてみた
スナップショットテストに関しては、おまけ的な感じで恐縮ではありますがこのサンプルでは共通のコンポーネントのみで行っています。
今回のテストReactNativeでプロジェクトを作成した際に入っているJestを利用した形になります。
Reduxを使用したり内部のロジックがさらに複雑になるようなケースではロジック部分にユニットテストのテストケースがあると心強いのですが、このサンプルではUIに関するサンプルなのでNativeBaseでの実装箇所の実体がどうなっているのかが興味があったのでスナップショットテストを記述してみました。
※ この部分の実装に関してはちょっとこの実装で正しいのかは少し自身がないです。ご指摘等あれば是非。
本家のドキュメント及び参考資料:
スナップショットテストの記述に関しては、Jestの公式ドキュメントを参考に記載をしました。スナップショットテストに関する公式ドキュメントはこちらになります。
またJestの概要やスナップショットテストに関しては下記の記事も参考にしました。
CommonCard.js(APIから取得したデータ表示用)のスナップショットテストコード例:
スナップショットテストに関するテストコードの一例としてCard型の見た目を作るコンポーネント(APIから取得したデータ表示用)に関するスナップショットテストをピックアップしてみました。
この部分に関しては、実際のコードではAPIのエンドポイントより非同期処理をaxios
を利用してサンプルデータを取得して表示する部分なので、APIでデータを取得する部分のデータをJestのMock関数を利用してAPI通信で取得するデータの肩代わりをさせます。
流れとしては、
-
jest.fn();
でモック関数を作成する。 -
mockReturnValue()
でモック関数の返り値を設定する
という感じにしています。
今回はスナップショットの中身を見てどんな感じでNativeBaseの<Card>
タグをはじめとする関連部分が実装されているかを見るためにこのような形にしました。
※ この部分のコンポーネントを使用した箇所についての一連の処理に関しては、「ShopList.js」をご参照ください。
import 'react-native';
import React from 'react';
import CommonCard from '../../../src/components/common/CommonCard';
// Note: test renderer must be required after react-native.
import renderer from 'react-test-renderer';
test('共通コンポーネント(店舗表示用部分)のスナップショットテスト', () => {
const shopDataMock = jest.fn();
shopDataMock.mockReturnValue(
{"shops":{
"contents":[
{
"id":"1",
"title":"大塚の油そば「麺屋 帝旺」",
"category":"ラーメン・油そば","kcpy":"ピリ辛の特製ラー油がうまさの秘訣!",
"detail":"少しピリ辛のラー油と食べ応えのある太麺が一度食べるとやみつきになる一品です。",
"image_url":"https://otsuka-shop.s3-ap-northeast-1.amazonaws.com/shops/images/1/large.jpg"}
]
}}
);
const tree = renderer.create(
<CommonCard shop={shopDataMock().shops.contents[0]} onPress={ () => console.log("テスト") } />
).toJSON();
expect(tree).toMatchSnapshot();
});
また、JestのMock関数に関する実装に関しては下記のドキュメントを参考にして実装した形になります。
スナップショットの中身:
スナップショットの中身に関しては、下記のような感じで出力される形になります。
ReactNative純正のコンポーネントだけで作成した自分なりのコンポーネントのみでスナップショットテストを行った時と比べると、適用されているスタイルやコンポーネントの構成は複雑になっているものの、元はReactNativeのコンポーネントを拡張したものだということがわかるかと思います。NativeBaseでUIを構築する場合はドキュメントと併用してNativeBaseのGithubにあるコードを読んでいくと実装の手助けになるかと思います。
ハマってしまった部分に関して:
今回の実装では、react-native-router-flux
を使用していた関係でそのままjestでのスナップショットテストを実行すると下記のようなエラーが出力されました。
sakaifumiyanoMacBook-Pro:LikeCustomTransition sakaifumiya$ npm test CommonCard.js --no-cache
> LikeCustomTransition@0.0.1 test /Users/sakaifumiya/Desktop/reactNativeApp/LikeCustomTransition
> jest "CommonCard.js"
FAIL __tests__/src/common/CommonCard.js
● Test suite failed to run
ENOENT: no such file or directory, stat '/Users/sakaifumiya/Desktop/reactNativeApp/LikeCustomTransition/node_modules/react-native-router-flux/node_modules/react-native/Libraries/Core/ErrorUtils.js'
at Object.fs.statSync (fs.js:968:11)
at Object.statSync (node_modules/graceful-fs/polyfills.js:297:22)
at Object.<anonymous> (node_modules/react-native/jest/setup.js:64:532)
当初は共通コンポーネントの中に遷移のアクションを記載していた感じでしたので、
- 共通コンポーネント内に記載していた遷移アクションを移動
-
react-native-router-flux
のバーションを3.38.0
に固定してライブラリを再インストール(package.json
の^
もはずす) -
$ sudo npm test
として実行
とすることでスナップショットテストは実行することができました。
ただ、画面遷移のライブラリは全体の設計をする上で重要な部分なので、他のライブラリも含めて検討は今後もしていこうとは思います。
(react-native-router-flux
自体は便利なのは間違いないのでバージョンアップに期待したいです...)
6. あとがき
ReactNativeに関しては自分なりにまだまだ手探り感が満載ではありますが、今年からSwiftでiOSアプリの開発に携わっているので、色々試して見極めようという感じです。
特に今まではUI実装と実現に関する記事が多かったこともあり、その流れにあやかって?ReactNativeでもUIまわりの実装サンプルを作ってみようと思った次第です。
クロスプラットフォームでコードの統一化はできる余地は多くあって便利ではありますが、UI面はiOS/Android間でのデザインの違いに対してどのようにアプローチをしていくかという課題は、実装の中でも悩ましい部分になるかもと個人的に感じていたので、自分なりにNativeBaseをはじめ、UIに関するライブラリを組み合わせて実装を行いました。
NativeBase自体はiOS/Android間でのデザインがある程度考慮されていることや、ReactNative純正のComponentともうまく組み合わせてよりネイティブアプリに近しいUIを作る上で、興味深いなと感じましたし、他のUI表現のライブラリと組み合わせて実装することでUIに一味加えたりすることもできるので、とても興味深いと感じました。
NativeBaseはReactNative純正のコンポーネントやサードパーティのライブラリを拡張して作られていたり、タグの階層構造である程度の見た目を実現してくれる部分はすごく便利ですが、反面細かくデザインを調整する際にはReactNative純正のComponentと組み合わせての実装が局面もあるので、この部分はUI作成を通じてもう少し深堀りしたい部分です。
これまでReactNativeに親しんできて3ヶ月ぐらいですが、動きが美しいUI系のライブラリを使ったUI実装やReduxを使ったステート管理の実装などの模索は、ネイティブアプリとは違った楽しさを感じています。