React Native 奇幻之旅(10)-樣式(Styling)

React Native logo

這是我在2023第十五屆 iThome 鐵人賽發表的系列文章。https://ithelp.ithome.com.tw/users/20136637/ironman/6408

React 是 component-based,遵循 CSS-in-JS 方法來設定組件樣式。CSS in JS 的主要優點之一是允許將樣式與組件直接綁定,能避免樣式命名衝突並且不用再單獨維護 CSS 檔案,但缺點是存在性能問題,因為它需要在運行時創建樣式物件。

React Native 內建 StyleSheet,使用 StyleSheet 建立樣式會得到一個 ID 而不是樣式物件,重新渲染會引用 ID,便可避免重複創建相同的樣式物件造成的性能問題。

接下來會介紹在 React Native 中設定組件樣式的基本方式。

Inline Style

可以直接在組件的 style 屬性中定義樣式,inline style 類似於 CSS 屬性,不過屬性名稱會使用小駝峰

定義一個長寬為 100% 的 View:

<View style={{ width: '100%', height: '100%' }}>
    // ...
</View>

如果需要合併樣式可以轉為陣列,比如想將已經定義好的樣式物件和 backgroundColor: 'skyblue' 合併,就可以使用陣列將它們合併 style={[styles.root, { backgroundColor: 'skyblue' }]}

注意:後定義的樣式會覆蓋先定義的樣式,如果 styles.root 中設置 backgroundColor: 'black',那最終背景色會是 skyblue

<View style={[styles.root, { backgroundColor: 'skyblue' }]}>
    // ...
</View>

inline style 也可以做計算,比如希望在系統為暗色主題時將背景色改為黑色就可以這樣寫:isDarkMode && { backgroundColor: 'black' }

<View style={[styles.root, isDarkMode && { backgroundColor: 'black' }]}>
    // ...
</View>

優點

  • 簡單、不需要另外維護樣式檔案。
  • 在需要計算、動態調整樣式時很方便。

缺點

  • 對於大型和複雜的樣式,inline style 會使程式碼可讀性變差且變得難以維護。
  • 每次渲染時都會創建樣式物件,導致性能下降。

StyleSheet

StyleSheet 是類似 CSS StyleSheets 的抽象,使用 StyleSheet 建立樣式會得到一個 ID 而不是樣式物件,寫法如下:

// App.tsx
import React from 'react';
import { StyleSheet, View } from 'react-native'

export const App = (): JSX.Element => {
  return (
    <View style={styles.root}>
      // ...
    </View>
  )
}

const styles = StyleSheet.create({
  root: {
    width: '100%',
    height: '100%'
  }
})

官方建議將 StyleSheet 和組件寫在一起會更簡潔,所以除非是需要共用的樣式,不然直接和組件寫在同一個檔案中就好。

As a component grows in complexity, it is often cleaner to use StyleSheet.create to define several styles in one place.
https://reactnative.dev/docs/style

優點

  • 將樣式從物件中抽離,提升程式碼易讀性和維護性。
  • 方便全局共用相同的樣式。
  • 使用 StyleSheet 建立樣式會得到一個 ID 而不是樣式物件,有助於提高性能。

缺點

  • 不適合動態調整樣式。

傳遞參數給 StyleSheet

有些時候會需要動態改變樣式,該如何使用 StyleSheet 接收參數呢?

比如 App 背景顏色要根據系統主題變化,但獲取系統主題色的方式是使用 hook,hook 只能在函數組件中使用,所以必須在組件中將系統主題色作為參數傳遞過來給 StyleSheet:

// styles/basee.ts
import { StyleSheet, StatusBar } from "react-native"
import { Colors } from 'react-native/Libraries/NewAppScreen'

interface BaseStylesProps {
  isDarkMode?: boolean
}

export const baseStyles = (props: BaseStylesProps) => StyleSheet.create({
  root: {
    backgroundColor: props.isDarkMode ? Colors.darker : Colors.lighter,
    width: '100%',
    height: '100%',
    marginTop: StatusBar.currentHeight ?? 0,
    padding: 16
  }
})
// App.tsx
import React from 'react'
import { View, useColorScheme } from 'react-native'
import { baseStyles } from '@/styles'

export const App = (): JSX.Element => {
  const isDarkMode = useColorScheme() === 'dark'

  return (
    <View style={baseStyles({ isDarkMode }).root}>
      // ...
    </View>
  )
}

但這樣就失去使用 StyleSheet 的意義了,所以盡量不要傳遞參數給 StyleSheet,需要計算的樣式直接用 inline style 就好。

實現偽類、偽元素

文章最開始有提到 React Native 中不允許寫偽類、偽元素,但是有別的方式可以去實現。這邊就簡單分享 :before, :after, :hover, :active 的間接實現方式。

:before :after

:before:after 可以使用額外的 View 或 Text 組件來模擬。

import React from 'react'
import { View, Text, StyleSheet } from 'react-native'

const App = () => {
  return (
    <View style={styles.container}>
      <Text style={styles.beforeAfterContent}>Before Content</Text>
      <Text style={styles.text}>Main Content</Text>
      <Text style={styles.beforeAfterContent}>After Content</Text>
    </View>
  )
}

const styles = StyleSheet.create({
  container: {
    flexDirection: 'row',
    alignItems: 'center'
  },
  beforeAfterContent: {
    marginRight: 10,
    color: 'red'
  },
  text: {
    color: 'black',
    fontSize: 16
  }
})

export default App

:hover :active

可以使用 Pressable 組件來模擬,Pressable 組件自帶 pressed 屬性,藉以判斷是否點擊組件。

import React from 'react'
import { View, Text, Pressable, StyleSheet } from 'react-native'

const App = () => {
    return (
        <Pressable
          style={({ pressed }) => [
            {
              backgroundColor: pressed ? "rgb(210, 230, 255)" : "white",
            },
          ]}
        >
          {({ pressed }) => (
            <Text style={styles.text}>{pressed ? "Pressed!" : "Press Me"}</Text>
          )}
        </Pressable>
    )
}

const styles = StyleSheet.create({
    text: {
        color: 'black',
        fontSize: 18
    }
})

export default App

其他的偽類偽元素也是差不多的實現方式。

修改 Android 預設文字顏色

如果使用 Text 組件時沒有設置文字顏色,在亮色主題下會是黑色,但如果切換成深色主題,文字就會變為空(透明)因此無法正常顯示文字:

<Text>TEST</Text>
ttNvlGl

這是因為 Android 應用預設使用 DayNight 配置,為了同時支持亮色和深色主題。

https://developer.android.com/develop/ui/views/theming/darktheme

可以在 android/app/src/main/res/values/styles.xml 中看到 AppTheme 為 DayNight:

<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
  ...
</style>

如果要解決這個問題,有幾種簡單的解決方式:

1.將 <style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar"> 改為 <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar"> 強制使用亮色主題。

2.在 android/app/src/main/res/values/styles.xml 中設置文字顏色

<resources>
    <style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
        <item name="android:textColor">#000000</item>
        ...
    </style>
    ...
</resources>

3.使用 Apperance 獲取主題模式,根據模式動態修改文字顏色。

import { useColorScheme, Text } from 'react-native'

const App = () => {
    const isDarkMode = useColorScheme() === 'dark'

    return (
        <Text style={{ color: isDarkMode ? '#ffffff' : '#000000' }}>DEMO</Text>
    )
}

補充:mixins

在 CSS 中設置 padding, margin 寫法如下:

paading: 10px 20px 40px 5px;
margin: 20px 10px;

在 RN 中寫法就會變得十分冗長:

{
    paddingTop: 10,
    paddingRight: 20,
    paddingBottom: 40,
    paddingLeft: 5,
    marginHorizontal: 10,
    marginVertical: 20
}

如果希望能在 RN 中和 CSS 一樣用一行就能定義 padding, margin 的話,可以寫一個函數來簡化這段樣式:

const dimensions = (t: number, r = t, b = t, l = r, prop: string): Record<string, number> => {
  let styles: Record<string, number> = {}
  styles[`${prop}Top`] = t
  styles[`${prop}Right`] = r
  styles[`${prop}Bottom`] = b
  styles[`${prop}Left`] = l
  return styles
}

const margin = (t: number, r: number, b?: number, l?: number) => {
  return dimensions(t, r, b, l, 'margin')
}

const padding = (t: number, r: number, b?: number, l?: number) => {
  return dimensions(t, r, b, l, 'padding')
}

export const mixins = {
  padding,
  margin
}
<View style={styles.container}>
    // ...
</View>

const styles = StyleSheet.create({
  container: {
    ...mixins.padding(8, 16)
  }
})

總結

  • RN 中 CSS 屬性使用小駝峰命名,如 justifyContent, alignItems
  • RN 中的默認單位為 dp,因此若設置 width: 200 則為 200dp
  • 部分屬性使用無單位的值,比如 borderRadius, padding, margin, fontSize…等
  • 無須計算的樣式建議使用 StyleSheet 而不是 inline style
  • Android 和 iOS 設置陰影為不同屬性,Android 使用 elevation,iOS 則使用 shadowOffset, shadowOpacity, shadowRadius
  • iOS 及 Android API 28 以上可使用 shadowColor 設置陰影顏色,Android API 低於 28 則用 elevation
  • React Native 中不允許使用偽類、偽元素,需要另外實現,或者可以使用 styled-componentsemotion
  • 如果要在 React Native 中使用媒體樣式,需要藉助第三方庫,如 react-native-media-query

參考資料

留言

目前沒有留言。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *