構建 F8 2016 App 第二部分:設計跨平臺App
這是為了介紹 React Native 和它的開源生態的一個系列教程,我們將以構建 F8 2016 開發者大會官方應用的 iOS 和 Android 版為主題。
React Native 的一大優勢是:可以只用一種語法編寫分別運行在 iOS 和 Android 平臺上的程序,且可重用部分應用邏輯。
然而,與“一次編寫,到處運行”的理念不同的是,React Native 的哲學是“一次學習,到處編寫”。如此一來,即使用 React Native 編寫不同平臺的程序,也可以盡可能貼合每個平臺的特性。
從 UI 的角度來看,每個平臺都有自己獨特的視覺風格、UI 范例甚或是技術層面的功能,那我們設計出一個統一的 UI 基礎組件,然后再按照各平臺特性進行調整豈不樂乎?
準備工作
在后續的所有教程中,我們會仔細解讀 App 的源代碼,請克隆一份源代碼到本地可訪問的路徑,然后根據配置說明在本地運行 App。在本章的教程中,你只需要閱讀相關源代碼。
React Native 思維模式
在你寫任何 React 代碼之前,請認真思考這個至關重要的問題:如何才能盡可能多地重用代碼?
React Native 的理念是針對每個平臺分而治之,代碼重用的做法看起來與之相違背,好像我們就應該為每個平臺定制其專屬的視覺組件一樣,但實際上我們仍需努力讓每個平臺上的代碼盡可能多地統一。
構建一套 React Native 應用視覺組件的關鍵點在于如何最好地實現平臺抽象。開發人員和設計師可以列出應用中需要重用的組件,例如按鈕、容器、列表行,頭部等等,只有在必要的時候才單獨為每個平臺設計特定的組件。
當然,有一些組件相對于其它組件而言更為復雜,我們先一起來看看 F8 應用中不同的組件有什么區別。
各種各樣的小組件
請看 F8 應用的示例圖:
在 iOS 版本中,我們用 iOS 系統中很常見的圓角邊框風格來切分 Tab 控制;在 Android 版本中,我們用下劃線的風格來標示這個組件。而這兩個控制組件的功能其實完全相同。
所以,即使兩者樣式稍有不同,但是實現的功能相同,所以我們可以用同一套代碼抽象此處的邏輯,從而可以盡可能多地重用代碼。
我們針對像這樣的小組件做了很多跨平臺重用邏輯代碼的案例,比如一個簡單的文本按鈕,在每個平臺上我們都會設計不同的 hover 和 active 狀態的樣式,但是除開這些視覺上的細微的差異外,邏輯功能完全相同。所以我們總結了一個抽象 React Native 視覺組件的最佳實踐方法:設計一套相同的邏輯代碼,然后在控制語句中編寫其余需要差異化的部分。
以下是這個組件的示例代碼(來自 <F8SegmentedControl>
):
/* from js/common/F8SegmentedControl.js */
class Segment extends React.Component {
props: {
value: string;
isSelected: boolean;
selectionColor: string;
onPress: () => void;
};
render() {
var selectedButtonStyle;
if (this.props.isSelected) {
selectedButtonStyle = { borderColor: this.props.selectionColor };
}
var deselectedLabelStyle;
if (!this.props.isSelected && Platform.OS === 'android') {
deselectedLabelStyle = styles.deselectedLabel;
}
var title = this.props.value && this.props.value.toUpperCase();
var accessibilityTraits = ['button'];
if (this.props.isSelected) {
accessibilityTraits.push('selected');
}
return (
<TouchableOpacity
accessibilityTraits={accessibilityTraits}
activeOpacity={0.8}
onPress={this.props.onPress}
style={[styles.button, selectedButtonStyle]}>
<Text style={[styles.label, deselectedLabelStyle]}>
{title}
</Text>
</TouchableOpacity>
);
}
}
在這段代碼中,我們為每一種平臺分別應用了不同的樣式(用到了 React Native 的 Platform 模塊)。各平臺中的 Tab 按鈕都應用了相同的通用樣式,同時也根據各平臺特性定制了獨占樣式(同樣出自 <F8SegmentedControl>
):
/* from js/common/F8SegmentedControl.js */
var styles = F8StyleSheet.create({
container: {
flexDirection: 'row',
backgroundColor: 'transparent',
ios: {
paddingBottom: 6,
justifyContent: 'center',
alignItems: 'center',
},
android: {
paddingLeft: 60,
},
},
button: {
borderColor: 'transparent',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'transparent',
ios: {
height: HEIGHT,
paddingHorizontal: 20,
borderRadius: HEIGHT / 2,
borderWidth: 1,
},
android: {
paddingBottom: 6,
paddingHorizontal: 10,
borderBottomWidth: 3,
marginRight: 10,
},
},
label: {
letterSpacing: 1,
fontSize: 12,
color: 'white',
},
deselectedLabel: {
color: 'rgba(255, 255, 255, 0.7)',
},
});
在這段代碼中我們使用了一個改編自 React Native StyleSheet
API 的函數 F8StyleSheet,它可以針對各平臺分別進行樣式轉換操作:
export function create(styles: Object): {[name: string]: number} {
const platformStyles = {};
Object.keys(styles).forEach((name) => {
let {ios, android, ...style} = {...styles[name]};
if (ios && Platform.OS === 'ios') {
style = {...style, ...ios};
}
if (android && Platform.OS === 'android') {
style = {...style, ...android};
}
platformStyles[name] = style;
});
return StyleSheet.create(platformStyles);
}
在這個 F8StyleSheet
函數中我們解析了前面示例代碼中的 styles
對象,如果我們發現了匹配當前平臺的 ios
或 android
鍵值,就會應用相應的樣式,如果都沒有,則應用默認樣式。以此看來,減少代碼重復的另一種做法是:盡可能多地重用通用樣式代碼。
現在,我們已經可以在我們的App中重用這個組件了,它也可以根據不同的平臺自動匹配相應的樣式。
分離復雜差異
如果一個組件在各平臺上的差異不僅僅是樣式的不同,也存在大量的邏輯代碼差異,那我們就需要換一種方式了。正如下圖所示,iOS 和 Android 平臺中最高階的菜單導航組件就有非常大的差異:
正如你所見,在 iOS 版本中我們在屏幕底部放了一個固定的 Tab,而在 Android 版本中,我們卻實現了一種可劃出的側邊欄。這兩種組件其實是本質上的不同,況且一般來說,在 Android 應用中,這種側邊欄通常還會包含更多的菜單選項,例如:退出登錄。
你當然可以將這兩種菜單模式寫到一個組件中去,但是這個組件會變得異常臃腫,所有的差異不得不通過大量的分支語句來實現,你一定會在不久的將來對這段代碼感到陌生難懂。
其實,我們可以用 React Native 內建的平臺特定的擴展來解決這個問題。我們可以創建兩個獨立的應用,在下面的示例中我們會創建兩個組件,分別命名為:F8TabsView.ios.js
和 F8TabsView.android.js
。React Native 會自動檢測當前平臺并根據擴展命名加載相應的組件。
內建UI組件
在每一個 FBTabsView
組件中,我們也可以重用一些內建的 React Native UI 組件,Android 版本使用的是DrawerLayoutAndroid
(很顯然,只在 Android 中可用):
/* from js/tabs/F8TabsView.android.js */
render() {
return (
<DrawerLayoutAndroid
ref="drawer"
drawerWidth={300}
drawerPosition={DrawerLayoutAndroid.positions.Left}
renderNavigationView={this.renderNavigationView}>
<View style={styles.content} key={this.props.activeTab}>
{this.renderContent()}
</View>
</DrawerLayoutAndroid>
);
}
在第8行代碼中,我們在當前的類中顯式地為 drawer 組件指定了 renderNavigationView()
函數。這個函數會返回 drawer 中渲染出來的內容。在這個示例中,我們渲染的是一個包含在自定義 MenuItem
組件(點擊查看 MenuItem.js
)中的 ScrollView
組件:
/* from js/tabs/F8TabsView.android.js */
renderNavigationView() {
...
return(
<ScrollView style={styles.drawer}>
<MenuItem
title="Schedule"
selected={this.props.activeTab === 'schedule'}
onPress={this.onTabSelect.bind(this, 'schedule')}
icon={scheduleIcon}
selectedIcon={scheduleIconSelected}
/>
<MenuItem
title="My F8"
selected={this.props.activeTab === 'my-schedule'}
onPress={this.onTabSelect.bind(this, 'my-schedule')}
icon={require('./schedule/img/my-schedule-icon.png')}
selectedIcon={require('./schedule/img/my-schedule-icon-active.png')}
/>
<MenuItem
title="Map"
selected={this.props.activeTab === 'map'}
onPress={this.onTabSelect.bind(this, 'map')}
icon={require('./maps/img/maps-icon.png')}
selectedIcon={require('./maps/img/maps-icon-active.png')}
/>
<MenuItem
title="Notifications"
selected={this.props.activeTab === 'notifications'}
onPress={this.onTabSelect.bind(this, 'notifications')}
badge={this.state.notificationsBadge}
icon={require('./notifications/img/notifications-icon.png')}
selectedIcon={require('./notifications/img/notifications-icon-active.png')}
/>
<MenuItem
title="Info"
selected={this.props.activeTab === 'info'}
onPress={this.onTabSelect.bind(this, 'info')}
icon={require('./info/img/info-icon.png')}
selectedIcon={require('./info/img/info-icon-active.png')}
/>
</ScrollView>
);
}
相比之下,iOS 版本直接在 render()
函數中使用了一個不同的內建組件,TabBarIOS
:
/* from js/tabs/F8TabsView.ios.js */
render() {
var scheduleIcon = this.props.day === 1
? require('./schedule/img/schedule-icon-1.png')
: require('./schedule/img/schedule-icon-2.png');
var scheduleIconSelected = this.props.day === 1
? require('./schedule/img/schedule-icon-1-active.png')
: require('./schedule/img/schedule-icon-2-active.png');
return (
<TabBarIOS tintColor={F8Colors.darkText}>
<TabBarItemIOS
title="Schedule"
selected={this.props.activeTab === 'schedule'}
onPress={this.onTabSelect.bind(this, 'schedule')}
icon={scheduleIcon}
selectedIcon={scheduleIconSelected}>
<GeneralScheduleView
navigator={this.props.navigator}
onDayChange={this.handleDayChange}
/>
</TabBarItemIOS>
<TabBarItemIOS
title="My F8"
selected={this.props.activeTab === 'my-schedule'}
onPress={this.onTabSelect.bind(this, 'my-schedule')}
icon={require('./schedule/img/my-schedule-icon.png')}
selectedIcon={require('./schedule/img/my-schedule-icon-active.png')}>
<MyScheduleView
navigator={this.props.navigator}
onJumpToSchedule={() => this.props.onTabSelect('schedule')}
/>
</TabBarItemIOS>
<TabBarItemIOS
title="Map"
selected={this.props.activeTab === 'map'}
onPress={this.onTabSelect.bind(this, 'map')}
icon={require('./maps/img/maps-icon.png')}
selectedIcon={require('./maps/img/maps-icon-active.png')}>
<F8MapView />
</TabBarItemIOS>
<TabBarItemIOS
title="Notifications"
selected={this.props.activeTab === 'notifications'}
onPress={this.onTabSelect.bind(this, 'notifications')}
badge={this.state.notificationsBadge}
icon={require('./notifications/img/notifications-icon.png')}
selectedIcon={require('./notifications/img/notifications-icon-active.png')}>
<F8NotificationsView navigator={this.props.navigator} />
</TabBarItemIOS>
<TabBarItemIOS
title="Info"
selected={this.props.activeTab === 'info'}
onPress={this.onTabSelect.bind(this, 'info')}
icon={require('./info/img/info-icon.png')}
selectedIcon={require('./info/img/info-icon-active.png')}>
<F8InfoView navigator={this.props.navigator} />
</TabBarItemIOS>
</TabBarIOS>
);
}
顯而易見,盡管 iOS 菜單接受了相同的數據,但是它的結構略有不同。我們并沒有用一個獨立的函數創建菜單元素,而是將這些元素作為父級菜單的子元素插入進來,正如 TabBarItemIOS
組件這樣。 這里的 TabBarItem
與 Android 中 的 MenuItem
本質上是相同的,唯一的區別是在 Android 組件中我們會定義一個獨立的主 View
組件:
<View style={styles.content} key={this.props.activeTab}>
{this.renderContent()}
</View>
然后當一個 tab 改變時改變這個組件(通過 renderContent()
函數),而 iOS 組件則會有多個分離的 View
組件,例如:
<GeneralScheduleView
navigator={this.props.navigator}
onDayChange={this.handleDayChange}
/>
這是 TabBarItem
的一部分,可以點擊使它們可見。
設計迭代周期
當你構建任何應用,無論是在移動平臺還是 web 環境下,調整適配的 UI 元素是非常痛苦的。如果工程師和設計師共同協作,會使整個過程慢下來。
React Native 包含了一個實時重載的 debug 功能,可以當 JavaScript 改變時觸發刷新應用。這可以在極大程度上減少設計迭代過程,一旦改變了組件樣式并保存后,你會立即看到更新的樣式。
但是如果組件在不同條件下看起來不同怎么辦呢?舉個例子,一個按鈕組件可能有一個默認樣式,也分別包含按下、執行任務中、執行任務完成時的樣式。
為了避免每次都與應用交互,我們內建了一個用于 debug 視覺效果的 Playgroud
組件:
/* from js/setup.js */
class Playground extends React.Component {
constructor(props) {
super(props);
const content = [];
const define = (name: string, render: Function) => {
content.push(<Example key={name} render={render} />);
};
var AddToScheduleButton = require('./tabs/schedule/AddToScheduleButton');
AddToScheduleButton.__cards__(define);
this.state = {content};
}
render() {
return (
<View style=>
{this.state.content}
</View>
);
}
}
其實我們只是創建了一個可交換加載的空視圖,將其與一些示例定義整合到其中一個 UI 組件中,正如下面這段AddToScheduleButton.js
所示:
/* from js/tabs/schedule/AddToScheduleButton.js */
module.exports.__cards__ = (define) => {
let f;
setInterval(() => f && f(), 1000);
define('Inactive', (state = true, update) =>
<AddToScheduleButton isAdded={state} onPress={() => update(!state)} />);
define('Active', (state = false, update) =>
<AddToScheduleButton isAdded={state} onPress={() => update(!state)} />);
define('Animated', (state = false, update) => {
f = () => update(!state);
return <AddToScheduleButton isAdded={state} />;
});
};
我們可以將這個應用轉化為一個 UI 預覽工具:
在這個示例中為這個按鈕定義了按下和抬起兩種狀態,第三個按鈕在這兩者的狀態之間不斷循環,以此來預覽過渡的動畫效果。
現在,我們可以跟設計師一起快速調整基礎組件的視覺樣式了。
如果想用這個功能,<Playground>
組件必須在任何 React Native 應用中都可用,我們需要在 setup()
函數中交換一些代碼來加載 <Playground>
組件:
/* from js/setup.js */
render() {
...
return (
<Provider store={this.state.store}>
<F8App />
</Provider>
);
}
變為
/* in js/setup.js */
render() {
...
return (
<Provider store={this.state.store}>
<Playground />
</Provider>
);
}
當然,你也可以修改 <Playground>
組件,使其能夠改變引入的其它組件。
下一篇:構建 F8 2016 App 第三部分:React Native的數據交互
來源:pockr