React Native 奇幻之旅(4)-內建組件 Modal

React Native logo

這是我在2023第十五屆 iThome 鐵人賽發表的系列文章。

https://ithelp.ithome.com.tw/users/20136637/ironman/6408

個人覺得 React Native 內建的 Modal 組件對初學者來說非常不友好,因為需要多做很多額外的處理,大部分時候我都會選擇使用 react-native-modal 或者 UI library 的 Modal 而不是 React Native 內建的 Modal,但還是要搞明白內建 Modal 最基本的使用方式。

基本使用

  • Modal 大小預設是全屏
  • Modal 的背景預設不是透明,因此看不見覆蓋在 Modal 底下的內容,如果要設為透明需要將 transparent 設為 true
  • 要 overlay 的話可以設 background 透明度 backgroundColor: 'rgba(0,0,0,.5)'

這是一個 Modal 的基本寫法:

import { StyleSheet, Modal, View, Text, ModalProps } from "react-native"

// ...

export const CustomModal = ({ visible, onRequestClose }: ModalProps) => {
  return (
    <Modal
      animationType="fade"
      transparent={true}
      visible={visible}
      onRequestClose={onRequestClose}
    >
      <View style={styles.centeredView}>
        <View style={styles.modalView}>
          <Text>Modal</Text>
        </View>
      </View>
    </Modal>
  )
}

const styles = StyleSheet.create({
  centeredView: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: 'rgba(0,0,0,.5)'
  },
  modalView: {
    width: 300,
    height: 350,
    margin: 20,
    backgroundColor: 'white',
    borderRadius: 5,
    padding: 35,
    alignItems: 'center'
  },
})
VV95y5T

點背景關閉 Modal

如果是上面的例子,點擊外面是無法關閉 Modal 的,這是因為 centeredView 其實占了整個畫面,因此也算是 Modal 的一部分,點擊 Modal 內部當然是無法關閉 modal 的。

jPpYS8q

如果要實現點擊 Modal 外部關閉 Modal 的話,可以將 TouchableWithoutFeedback 組件包裹在 Modal 中:

<Modal
  animationType="fade"
  transparent={true}
  visible={visible}
>
  <TouchableWithoutFeedback onPress={onRequestClose}>
    <View style={styles.centeredView}>
      <View style={styles.modalView}>
        <Text>Modal</Text>
      </View>
    </View>
  </TouchableWithoutFeedback>
</Modal>

但這樣做的話不管是點 Modal 內部還是外部都一樣會關閉 Modal,所以我們需要判斷點擊的是否為 Modal 外部,如果是才關閉:

const onBackdropPress = (event: GestureResponderEvent) => {
    if (event.target === event.currentTarget) {
      onRequestClose && onRequestClose(event)
      return
    }
}
  • event.target: 用戶實際點擊或操作的那個元素。
  • event.currentTarget: 當前正在處理事件的元素,可以理解為事件目前正在”冒泡到”的元素。
  • event.target === event.currentTarget 用於判斷是否點擊的是 Modal 外部。
import { StyleSheet, Modal, View, Text, TouchableWithoutFeedback, ModalProps, GestureResponderEvent } from "react-native"

export const CustomModal = ({ visible, onRequestClose }: ModalProps) => {
  const onBackdropPress = (event: GestureResponderEvent) => {
    if (event.target === event.currentTarget) {
      onRequestClose && onRequestClose(event)
      return
    }
  }

  return (
    <Modal
      animationType="fade"
      transparent={true}
      visible={visible}
    >
      <TouchableWithoutFeedback onPress={onBackdropPress}>
        <View style={styles.centeredView}>
          <View style={styles.modalView}>
            <Text>Modal</Text>
          </View>
        </View>
      </TouchableWithoutFeedback>
    </Modal>
  )
}

Modal 內容滾動

當 Modal 中的內容過長時 Modal 會無限增長直到超出屏幕的高度:

q1EBFS1

要解決也很簡單,直接限制 Modal 高度然後加個 ScrollView 就行
(如果設 maxHeight 就是只有超過這個高度才會需要滾動)

import { StyleSheet, Modal, ScrollView, View, Text, TouchableWithoutFeedback, ModalProps, GestureResponderEvent } from "react-native"

export const CustomModal = ({ visible, onRequestClose }: ModalProps) => {
  const onBackdropPress = (event: GestureResponderEvent) => {
    if (event.target === event.currentTarget) {
      onRequestClose && onRequestClose(event)
      return
    }
  }

  return (
    <Modal
      animationType="fade"
      transparent={true}
      visible={visible}
    >
      <TouchableWithoutFeedback onPress={onBackdropPress}>
        <View style={styles.centeredView}>
          <View style={styles.modalView}>
            <ScrollView>
              <Text>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</Text>
            </ScrollView>
          </View>
        </View>
      </TouchableWithoutFeedback>
    </Modal>
  )
}
sX47Ugr

全局控制 Modal

有些頁面需要控制多個 Modal 或者有些 Modal 需要在多個頁面被使用,如果每個頁面都寫上 Modal 相關的程式碼那就會變得十分臃腫,所以可以把 Modal 抽象出來當作全局組件。

最簡單的實現全局 Modal 的方式是使用 Context + Provider

我們可以寫兩個方法供全局控制Modal的開關:

  • openModal: 給定 Modal 名稱,彈出指定 Modal
  • closeModal: 關閉當前 Modal,初始化狀態
// _types_/modal.ts
export interface ModalContextType {
  openModal(key: string): void
  closeModal(): void
}

新增 Context 和 Provider:

  • visible: Modal 彈出狀態
  • modalType: 決定顯示哪個 Modal
// context/modalContext.ts
import { createContext } from "react"
import { ModalContextType } from "_types_"

export const ModalContext = createContext<ModalContextType>({
  openModal: () => {},
  closeModal: () => {},
})
// provider/ModalProvider.tsx
import React, { useState, useMemo, useCallback } from "react"
import { CustomModal, DeleteModal } from "@/components/organisms"
import { ModalContext } from "@/context"

const MODAL_COMPONENTS = {
  default: CustomModal
  delete: DeleteModal
}

export interface ModalProviderProps {
  children: React.ReactNode
}

export const ModalProvider = ({ children }: ModalProviderProps) => {
  const [visible, setVisible] = useState(false)
  const [modalType, setModalType] = useState<string>('')

  const openModal = useCallback((key: string) => {
    setVisible(true)
    setModalType(key)
  }, [])

  const closeModal = useCallback(() => {    
    setVisible(false)
    setModalType('')
  }, [])

  const contextValue = useMemo(() => ({ openModal, closeModal }), [])

  const renderModal = () => {
    const ModalContent = MODAL_COMPONENTS[modalType as keyof typeof MODAL_COMPONENTS] ?? MODAL_COMPONENTS.default
    if (!modalType || !ModalContent) return null
    return <ModalContent visible={visible} onRequestClose={closeModal} />
  }

  return (
    <ModalContext.Provider value={contextValue}>
      {renderModal()}
      {children}
    </ModalContext.Provider>
  )
}

在根組件外加上 ModalProvider,這樣一來所有組件都能調用 openModal, closeModal

// App.tsx
import React from 'react'
import { StyleSheet, Text, View } from 'react-native'
import { ModalProvider } from '@/provider/ModalProvider'

export default function App() {
  return (
    <ModalProvider>
      <View style={styles.container}>
        // ...
      </View>
    </ModalProvider>
  )
}

如果要在頁面中控制 Modal,只需要使用 useContext(ModalContext),但每個頁面都要 import context 和 useContext 再去獲取 openModal, closeModal 還是不夠方便,所以可以再寫一個 hook 來精簡:

// hooks/useModal.ts
import { useContext } from "react"
import { ModalContext } from "@/context"

export const useModal = () => {
  const context = useContext(ModalContext)
  return context
}

使用 hook 調用 Modal 方式:

import React from "react"
import { View, Button } from "react-native"
import { useModal } from "@/hooks/useModal"

export const Page = () => {
  const { openModal, closeModal } = useModal()

  return (
    <View style={styles.container}>
        <Button title="Delete" onPress={() => openModal('delete')} />
    </View>
  )
}

除了 Modal 以外,其他希望全局使用的組件也可以利用 Context + Provider 的方式實現。

Toast 被覆蓋在 Modal 底下

在 Modal 開啟時彈出 Toast 是會被覆蓋在底下的,但又很常會遇到這樣的需求,該怎麼解決呢? (這邊使用的是 react-native-toast-message)

HK6OnSM
import Toast from 'react-native-toast-message'
import { ModalProvider } from '@/provider/ModalProvider'

export default function App() {
  return (
    <ModalProvider>
      <View style={styles.container}>
        // ...
      </View>
      <Toast />
    </ModalProvider>
  );
}

解決方式

這個問題是無法用 zIndex 解決的,不過還有以下幾種方式可以解決這個問題:

  1. 用 React Native 內建的 ToastAndroid,因為它的層級已經被設置為可在 Modal 上方顯示,不過僅限 Android 使用
  2. 使用 react-native-simple-toast,不會被 Modal 遮擋的 Toast,適用於 Android 和 iOS。
  3. 在 Modal 中和 Modal 外各放一個 <Toast /> (僅限 react-native-toast-message 的解法)
// Modal
export const CustomModal = ({ visible, onRequestClose }: CustomModalProps) => {
  // ...
  const showToast = () => {
    Toast.show({
      type: 'success',
      text1: 'Hello',
      text2: 'This is some something 👋'
    });
  }

  return (
    <Modal
      animationType="fade"
      transparent={true}
      visible={visible}
    >
      <TouchableWithoutFeedback onPress={onBackdropPress}>
        // ...
      </TouchableWithoutFeedback>
      <Toast />
    </Modal>
  )
}
// App.tsx
import React from 'react'
import { StyleSheet, View, LogBox } from 'react-native'
import Toast from 'react-native-toast-message'
import { ModalProvider } from './src/provider/ModalProvider'


export default function App() {

  return (
    <ModalProvider>
      <View style={styles.container}>
        // ...
      </View>
      <Toast />
    </ModalProvider>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#ddd',
    justifyContent: 'center',
    alignItems: 'center'
  }
})

不過就是需要注意會有多重 toast 的情況發生

WMboO7r

Modal彈出時TextInput不會自動失焦

若在當前畫面聚焦 TextInput 後彈出 Modal,預期的情況是 TextInput 會自動失去焦點,但實際上 TextInput 並不會自動失焦(即不會觸發 onBlur),鍵盤因此無法自動收起,會擋住Modal中的內容。

wWHHezH

Github issues 也有人提出了這個bug,但至今仍未修復。目前來說並沒有一個完美的解決方案,只能在開啟 Modal 前將 Keyboard 關閉。

import { Keyboard } from 'react-native'

const openModal = () => {
    if (Keyboard.isVisible()) {
      Keyboard.dismiss()
    }
    // ...
}

留言

目前沒有留言。

發佈留言

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