react native 弹窗浮层的管理方式

发布于 2023-05-11
5 分钟

背景

在 react native 需求开发中,经常遇到浮层的需求,即 DialogBottomDrawerModal 等浮层组件,但内部的公共组件库总是难以满足需求,例如浮层层叠,操作浮层的方式,获取操作的结果等,需要二次开发。

现有浮层实现相似点

研究下了内部组件库的实现,实现上有相同,都有相似的展示浮层,关闭浮层的状态管理逻辑,

export class Dialog extends PureComponent {
  static showPopup = (config) => { // 👈🏻 操作显示浮层
    return new Promise((resolve) => {
      const inst = Dialog.instances[pageId];
      if (inst) {
        inst.setState({
          show: true,
          config,
        });
      }
    });
  };

  handleDismiss = (result) => {
    // 👇🏻 隐藏浮层
    this.setState({ show: false }, () => this.resolve?.({ action: result }));
  };

  render() {
    const { show } = this.state;
    if (!show) {
      return null;
    }
    return (
      <View>
        <TouchableOpacity onPress={() => this.handleDismiss(Dialog.Action.Cancel)}>
          <Text>Cancel</Text>
        </TouchableOpacity>
      </View>
    );
  }
}

现有浮层实现不同点

使用上又有些不同,Dialog 渲染的位置会放在顶层组件树,然后在子组件内部通过 Dialog.showPopup(config) 配置并显示 Dialog

function Screen() {
  return (
    <View>
      <View>
        <Child />
      </View>
      <Dialog /> {/* 👈🏻 在这里渲染 Dialog */}
    </View>
  );
}
function Child() {
  const handlePress = () => {
    const result = await Dialog.showPopup(config); // 👈🏻 配置并显示 Dialog,获取操作 Dialog 的结果
  };
  return <View onPress={handlePress}>{/* ... */}</View>;
}

BottomDrawerModal 放到组件树,当普通组件引用,其实为了覆盖整个屏幕,也必须放在页面的顶层组件树中

这个方式每个页面都要维护一套展示和隐藏的状态逻辑

function Screen() {
  return (
    <View>
      <View>
        <Child />
      </View>
      <BottomDrawer show={state.showContent1}>{/* content */}</BottomDrawer>
      <BottomDrawer show={state.showContent2}>{/* content */}</BottomDrawer>
      <Modal visible={state.visible}>{/* content */}</Modal>
    </View>
  );
}

存在问题

这样维护起来有些混乱,每次需求开发都要把组件二次改造一下,要么页面存在大量展示隐藏的状态逻辑,使用方式也不一致,多次迭代后就得花更多时间学习研究组件怎么用,要么新的开发者又重新写一套。

实际打开浮层,层叠展示多个浮层,关闭浮层,它们的状态管理逻辑基本一样,只是在表现层不一样,因此可以自定义一个 hook,提取它们的共同逻辑。

代码实现

用方法的形式操作浮层,提供 openclosecloseAll 3 个方法

import { ReactNode, useContext, useEffect, useMemo, useRef } from 'react';
import { Keyboard, Platform, BackHandler } from 'react-native';
import useCallbackState from './useCallbackState';

type TPopUpInfo = {
  shouldShow: boolean;
  component: ReactNode;
  onClose: () => void;
};

export type TManageStack = {
  open: (renderComponent: (onClose: () => void) => ReactNode) => number;
  close: (popUpId: number) => void;
  closeAll: () => void;
};

/**
 * Dialog, BottomDrawer, Modal etc.
 */
export const usePopupStack = () => {
  const [list, setList] = useCallbackState<TPopUpInfo[]>([]);
  const popUpMap = useRef<Map<number, TPopUpInfo>>(new Map());
  const manageStack = useMemo((): TManageStack => {
    return {
      open: (renderComponent) => {
        const popUpId = Date.now();
        const onClose = () => {
          // 快速点击会执行多次
          if (popUpMap.current.get(popUpId)) {
            popUpMap.current.get(popUpId)!.shouldShow = false;
            setList(cloneList(popUpMap.current), () => {
              popUpMap.current.delete(popUpId);
            });
          }
        };
        const component = renderComponent(onClose);
        popUpMap.current.set(popUpId, {
          shouldShow: true,
          component,
          onClose,
        });
        Keyboard.dismiss();
        setList(cloneList(popUpMap.current)); // 👈🏻 不能渲染 ref,把 list clone 存到 state 上
        return popUpId;
      },
      close: (popUpId: number) => {
        popUpMap.current.get(popUpId)?.onClose();
      },
      closeAll: () => {
        [...popUpMap.current.values()].forEach((popup) => {
          popup.onClose();
        });
      },
    };
  }, [setList]);

  useEffect(() => {
    // 监听 android 的实体返回键,关闭 pop up
    if (Platform.OS === 'android') {
      BackHandler.addEventListener('hardwareBackPress', function () {
        if (popUpMap.current) {
          const topPopupId = Array.from(popUpMap.current.keys()).pop();
          if (topPopupId) {
            manageStack.close(topPopupId);
          }
        }
      });
    }
    return () => {
      if (Platform.OS === 'android') {
        BackHandler.removeEventListener('hardwareBackPress');
      }
    };
  }, [manageStack]);

  return { manageStack, list };
};

用法

每种浮层都用 usePopupStack 管理,用 context 往下传递 openclosecloseAll 3 个方法

import React, { createContext, PropsWithChildren } from 'react';
import BottomDrawer from '//ButtomDrawer';
import { usePopupStack } from './usePopupStack';
import type { TManageStack } from './usePopupStack';

export const BottomDrawerContext = createContext({} as TManageStack);

export const BottomDrawerContextProvider: React.FC<PropsWithChildren> = (props) => {
  const { manageStack: bottomDrawer, list: drawerList } = usePopupStack();

  return (
    <BottomDrawerContext.Provider value={bottomDrawer}>
      {props.children}
      {drawerList.map(
        // 渲染 open 的 BottomDrawer
        ({ component, shouldShow, onClose }, index) => (
          <BottomDrawer
            key={index}
            shouldShow={shouldShow}
            onDismiss={onClose}
          >
            {component}
          </BottomDrawer>
        )
      )}
    </BottomDrawerContext.Provider>
  );
};

浮层组件都放到顶层组件树渲染,这样浮层可以覆盖整个页面。

function Screen() {
  <BottomDrawerContextProvider>
    <DialogContextProvider>
      <View>...</View>
    </DialogContextProvider>
  </BottomDrawerContextProvider>;
}

子组件从 context 获取 openclosecloseAll 方法

function Child() {
  const bottomDrawer = useContext(BottomDrawerContext);
  const handle = () => {
    const drawerId = bottomDrawer.open((onClose) => {
      return (
        <View>
          <Button onPress={onClose}>Close</Button>
        </View>
      );
    });
  };

  useEffect(() => {
    return () => {
      bottomDrawer.close(drawerId);
    };
  }, []);

  return <></>;
}

这样,抽取一个 hook,每个组件不用维护浮层的状态,使用也简洁清晰。

本博客 所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!