構建 F8 2016 App 第二部分:設計跨平臺App

RosGehlert 8年前發布 | 55K 次閱讀 ReactNative 移動開發 IOS Android

上一篇:構建 F8 2016 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 and Android Segmented Controls Comparison

在 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 and Android Main Navigation Comparison

正如你所見,在 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 預覽工具:

UI preview playground in action with a button and three different states

在這個示例中為這個按鈕定義了按下和抬起兩種狀態,第三個按鈕在這兩者的狀態之間不斷循環,以此來預覽過渡的動畫效果。

現在,我們可以跟設計師一起快速調整基礎組件的視覺樣式了。

如果想用這個功能,<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

 本文由用戶 RosGehlert 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!