diff --git a/package.json b/package.json index 93d58ade94..6242ccfc53 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,7 @@ "async-validator": "^4.2.5", "classnames": "^2.5.1", "codesandbox": "^2.2.3", + "dom-helpers": "5.2.1", "lodash.kebabcase": "^4.1.1", "lottie-react": "^2.4.0", "react-fast-compare": "^3.2.2", diff --git a/src/packages/overlay/overlay.taro.tsx b/src/packages/overlay/overlay.taro.tsx index 328835b230..8ebd540fb2 100644 --- a/src/packages/overlay/overlay.taro.tsx +++ b/src/packages/overlay/overlay.taro.tsx @@ -1,7 +1,7 @@ import React, { FunctionComponent, useEffect, useState } from 'react' -import { CSSTransition } from 'react-transition-group' import classNames from 'classnames' import { ITouchEvent, View } from '@tarojs/components' +import CSSTransition from '@/utils/css-transition/CSSTransition' import { ComponentDefaults } from '@/utils/typings' import { useLockScrollTaro } from '@/hooks/taro/use-lock-scoll' import { TaroOverlayProps } from '@/types' diff --git a/src/packages/overlay/overlay.tsx b/src/packages/overlay/overlay.tsx index 98667d689b..56227fdc69 100644 --- a/src/packages/overlay/overlay.tsx +++ b/src/packages/overlay/overlay.tsx @@ -5,8 +5,8 @@ import React, { useRef, useState, } from 'react' -import { CSSTransition } from 'react-transition-group' import classNames from 'classnames' +import CSSTransition from '@/utils/css-transition/CSSTransition' import { ComponentDefaults } from '@/utils/typings' import { useLockScroll } from '@/hooks/use-lock-scroll' import { WebOverlayProps } from '@/types' diff --git a/src/utils/css-transition/CSSTransition.tsx b/src/utils/css-transition/CSSTransition.tsx new file mode 100644 index 0000000000..d61befc02d --- /dev/null +++ b/src/utils/css-transition/CSSTransition.tsx @@ -0,0 +1,201 @@ +import addOneClass from 'dom-helpers/addClass' + +import removeOneClass from 'dom-helpers/removeClass' +import React, { FunctionComponent } from 'react' + +import { Transition, TransitionProps } from './Transition' +import { forceReflow } from './utils/reflow' + +const addClassCommon = (node: HTMLElement | null, classes: string) => + node && classes && classes.split(' ').forEach((c) => addOneClass(node, c)) +const removeClassCommon = (node: HTMLElement | null, classes: string) => + node && classes && classes.split(' ').forEach((c) => removeOneClass(node, c)) + +type ClassNamesShape = + | string + | { + appear: string + appearActive: string + appearDone: string + enter: string + enterActive: string + enterDone: string + exit: string + exitActive: string + exitDone: string + } + +interface CSSTransitionProps extends TransitionProps { + classNames: ClassNamesShape + children: React.ReactNode +} + +const defaultProps = { + classNames: '', +} + +const CSSTransition: FunctionComponent> = ( + props +) => { + const { + classNames, + onEnter: _onEnter, + onEntering: _onEntering, + onEntered: _onEntered, + onExit: _onExit, + onExiting: _onExiting, + onExited: _onExited, + nodeRef: _nodeRef, + ...childProps + } = { ...defaultProps, ...props } + + const appliedClasses = { + appear: {}, + enter: {}, + exit: {}, + } + + const onEnter = (maybeNode: any, maybeAppearing: boolean) => { + const [node, appearing] = resolveArguments(maybeNode, maybeAppearing) + removeClasses(node, 'exit') + addClass(node, appearing ? 'appear' : 'enter', 'base') + + if (_onEnter) { + _onEnter(maybeNode, maybeAppearing) + } + } + + const onEntering = (maybeNode: any, maybeAppearing: boolean) => { + const [node, appearing] = resolveArguments(maybeNode, maybeAppearing) + const type = appearing ? 'appear' : 'enter' + addClass(node, type, 'active') + + if (_onEntering) { + _onEntering(maybeNode, maybeAppearing) + } + } + + const onEntered = (maybeNode: any, maybeAppearing: boolean) => { + const [node, appearing] = resolveArguments(maybeNode, maybeAppearing) + const type = appearing ? 'appear' : 'enter' + removeClasses(node, type) + addClass(node, type, 'done') + + if (_onEntered) { + _onEntered(maybeNode, maybeAppearing) + } + } + + const onExit = (maybeNode: any) => { + const [node] = resolveArguments(maybeNode) + removeClasses(node, 'appear') + removeClasses(node, 'enter') + addClass(node, 'exit', 'base') + + if (_onExit) { + _onExit(maybeNode) + } + } + + const onExiting = (maybeNode: any) => { + const [node] = resolveArguments(maybeNode) + addClass(node, 'exit', 'active') + + if (_onExiting) { + _onExiting(maybeNode) + } + } + + const onExited = (maybeNode: any) => { + const [node] = resolveArguments(maybeNode) + removeClasses(node, 'exit') + addClass(node, 'exit', 'done') + + if (_onExited) { + _onExited(maybeNode) + } + } + + // when prop `nodeRef` is provided `node` is excluded + const resolveArguments = (maybeNode: any, maybeAppearing: boolean) => + _nodeRef + ? [_nodeRef.current, maybeNode] // here `maybeNode` is actually `appearing` + : [maybeNode, maybeAppearing] // `findDOMNode` was used + + const getClassNames = (type: string) => { + const isStringClassNames = typeof classNames === 'string' + const prefix = isStringClassNames && classNames ? `${classNames}-` : '' + + const baseClassName = isStringClassNames + ? `${prefix}${type}` + : classNames[type] + + const activeClassName = isStringClassNames + ? `${baseClassName}-active` + : classNames[`${type}Active`] + + const doneClassName = isStringClassNames + ? `${baseClassName}-done` + : classNames[`${type}Done`] + + return { + baseClassName, + activeClassName, + doneClassName, + } + } + + const addClass = (node: HTMLElement | null, type: string, phase: string) => { + let className = getClassNames(type)[`${phase}ClassName`] + const { doneClassName } = getClassNames('enter') + + if (type === 'appear' && phase === 'done' && doneClassName) { + className += ` ${doneClassName}` + } + + // This is to force a repaint, + // which is necessary in order to transition styles when adding a class name. + if (phase === 'active') { + if (node) forceReflow(node) + } + + if (className) { + appliedClasses[type][phase] = className + addClassCommon(node, className) + } + } + + const removeClasses = (node: HTMLElement | null, type: string) => { + const { + base: baseClassName, + active: activeClassName, + done: doneClassName, + } = appliedClasses[type] + + appliedClasses[type] = {} + + if (baseClassName) { + removeClassCommon(node, baseClassName) + } + if (activeClassName) { + removeClassCommon(node, activeClassName) + } + if (doneClassName) { + removeClassCommon(node, doneClassName) + } + } + + return ( + + ) +} + +export default CSSTransition diff --git a/src/utils/css-transition/Transition.tsx b/src/utils/css-transition/Transition.tsx new file mode 100644 index 0000000000..9326012d5e --- /dev/null +++ b/src/utils/css-transition/Transition.tsx @@ -0,0 +1,288 @@ +import React, { + FunctionComponent, + useEffect, + useRef, + useState, + useContext, +} from 'react' +import config from './config' +import TransitionGroupContext from './TransitionGroupContext' +import { forceReflow } from './utils/reflow' + +export const UNMOUNTED = 'unmounted' +export const EXITED = 'exited' +export const ENTERING = 'entering' +export const ENTERED = 'entered' +export const EXITING = 'exiting' + +export interface TransitionProps { + in: boolean + mountOnEnter: boolean + unmountOnExit: boolean + appear: boolean + enter: boolean + exit: boolean + timeout?: number | { enter?: number; exit?: number; appear?: number } + addEndListener?: (node: HTMLElement, done: () => void) => void + onEnter?: (node: HTMLElement, appearing: boolean) => void + onEntering?: (node: HTMLElement, appearing: boolean) => void + onEntered?: (node: HTMLElement, appearing: boolean) => void + onExit?: (node: HTMLElement) => void + onExiting?: (node: HTMLElement) => void + onExited?: (node: HTMLElement) => void + nodeRef?: React.RefObject + children: + | React.ReactNode + | ((status: string, childProps: any) => React.ReactNode) +} + +const defaultProps = { + in: false, + mountOnEnter: false, + unmountOnExit: false, + appear: false, + enter: true, + exit: true, + onEnter: () => {}, + onEntering: () => {}, + onEntered: () => {}, + onExit: () => {}, + onExiting: () => {}, + onExited: () => {}, +} + +export const Transition: FunctionComponent> = ( + props +) => { + const { + children, + // filter props for `Transition` + in: _in, + mountOnEnter, + unmountOnExit, + appear, + enter, + exit, + timeout, + addEndListener, + onEnter, + onEntering, + onEntered, + onExit, + onExiting, + onExited, + nodeRef: _nodeRef, + ...childProps + } = { ...defaultProps, ...props } + const context = useContext(TransitionGroupContext) + const [status, setStatus] = useState(UNMOUNTED) + const nextCallback = useRef(null) + const ref = useRef(null) + const nodeRef = _nodeRef || ref + + useEffect(() => { + // In the context of a TransitionGroup all enters are really appears + const appear = context && !context.isMounting ? enter : props.appear + + let initialStatus + if (_in) { + if (appear) { + initialStatus = EXITED + } else { + initialStatus = ENTERED + } + } else if (unmountOnExit || mountOnEnter) { + initialStatus = UNMOUNTED + } else { + initialStatus = EXITED + } + + setStatus(initialStatus) + }, [_in, enter, props.appear, unmountOnExit, mountOnEnter, context]) + + useEffect(() => { + if (status === EXITED && unmountOnExit) { + setStatus(UNMOUNTED) + } else { + updateStatus(true, null) + } + }, [status, unmountOnExit]) + + useEffect(() => { + let nextStatus = null + if (_in) { + if (status !== ENTERING && status !== ENTERED) { + nextStatus = ENTERING + } + } else if (status === ENTERING || status === ENTERED) { + nextStatus = EXITING + } + console.log('nextStatus', nextStatus) + updateStatus(false, nextStatus) + }, [_in, status]) + + useEffect(() => { + return () => { + cancelNextCallback() + } + }, []) + + const getTimeouts = () => { + const { timeout } = props + let exit = timeout + let enter = timeout + let appear = timeout + + if (timeout != null && typeof timeout !== 'number') { + exit = timeout.exit + enter = timeout.enter + // TODO: remove fallback for next major + appear = timeout.appear !== undefined ? timeout.appear : enter + } + return { exit, enter, appear } + } + + const updateStatus = (mounting: boolean, nextStatus: string | null) => { + if (nextStatus !== null) { + // nextStatus will always be ENTERING or EXITING. + cancelNextCallback() + + if (nextStatus === ENTERING) { + if (unmountOnExit || mountOnEnter) { + const node = nodeRef.current + // https://github.com/reactjs/react-transition-group/pull/749 + // With unmountOnExit or mountOnEnter, the enter animation should happen at the transition between `exited` and `entering`. + // To make the animation happen, we have to separate each rendering and avoid being processed as batched. + if (node) forceReflow(node) + } + performEnter(mounting) + } else { + performExit() + } + } else if (unmountOnExit && status === EXITED) { + setStatus(UNMOUNTED) + } + } + + const performEnter = (mounting: boolean) => { + const appearing = context ? context.isMounting : mounting + const maybeNode = nodeRef.current + + const timeouts = getTimeouts() + const enterTimeout = appearing ? timeouts.appear : timeouts.enter + + console.log('performEnter') + // no enter animation skip right to ENTERED + // if we are mounting and running this it means appear _must_ be set + if ((!mounting && !enter) || config.disabled) { + safeSetState({ status: ENTERED }, () => { + onEntered(maybeNode) + }) + return + } + + onEnter(maybeNode, appearing) + safeSetState({ status: ENTERING }, () => { + onEntering(maybeNode, appearing) + onTransitionEnd(enterTimeout, () => { + safeSetState({ status: ENTERED }, () => { + onEntered(maybeNode, appearing) + }) + }) + }) + } + + const performExit = () => { + const maybeNode = nodeRef.current + + // no exit animation skip right to EXITED + if (!exit || config.disabled) { + safeSetState({ status: EXITED }, () => { + onExited(maybeNode) + }) + return + } + + onExit(maybeNode) + safeSetState({ status: EXITING }, () => { + onExiting(maybeNode) + const timeouts = getTimeouts() + onTransitionEnd(timeouts.exit, () => { + safeSetState({ status: EXITED }, () => { + onExited(maybeNode) + }) + }) + }) + } + + const cancelNextCallback = () => { + if (nextCallback.current !== null) { + nextCallback.current.cancel() + nextCallback.current = null + } + } + + const safeSetState = (nextState: any, callback?: () => void) => { + // This shouldn't be necessary, but there are weird race conditions with + // setState callbacks and unmounting in testing, so always make sure that + // we can cancel any pending setState callbacks after we unmount. + const nextCallback = setNextCallback(callback) + setStatus((prev) => ({ ...prev, ...nextState }), nextCallback) + } + + const setNextCallback = (callback?: () => void) => { + let active = true + + nextCallback.current = (event: any) => { + if (active) { + active = false + nextCallback.current = null + callback?.(event) + } + } + + nextCallback.current.cancel = () => { + active = false + } + + return nextCallback.current + } + + const onTransitionEnd = (timeout: number | null, handler: () => void) => { + setNextCallback(handler) + const node = nodeRef.current + + const doesNotHaveTimeoutOrListener = timeout == null && !addEndListener + if (!node || doesNotHaveTimeoutOrListener) { + setTimeout(nextCallback.current, 0) + return + } + + if (addEndListener) { + addEndListener(node, nextCallback.current) + } + + if (timeout != null) { + setTimeout(nextCallback.current, timeout) + } + } + + if (status === UNMOUNTED) { + return null + } + + return ( + // allows for nested Transitions + + {typeof children === 'function' + ? children(status, childProps) + : React.cloneElement(React.Children.only(children), childProps)} + + ) +} + +Transition.UNMOUNTED = UNMOUNTED +Transition.EXITED = EXITED +Transition.ENTERING = ENTERING +Transition.ENTERED = ENTERED +Transition.EXITING = EXITING diff --git a/src/utils/css-transition/TransitionGroupContext.tsx b/src/utils/css-transition/TransitionGroupContext.tsx new file mode 100644 index 0000000000..46abec3b61 --- /dev/null +++ b/src/utils/css-transition/TransitionGroupContext.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +/** + * TransitionGroup 上下文,用于管理过渡组中的挂载行为和状态协调 + */ +interface TransitionGroupContextValue { + isMounting?: boolean + // 根据实际使用场景添加其他属性 +} + +export default React.createContext(null) diff --git a/src/utils/css-transition/config.ts b/src/utils/css-transition/config.ts new file mode 100644 index 0000000000..a726e96eab --- /dev/null +++ b/src/utils/css-transition/config.ts @@ -0,0 +1,3 @@ +export default { + disabled: false, +} diff --git a/src/utils/css-transition/index.ts b/src/utils/css-transition/index.ts new file mode 100644 index 0000000000..0d708a18d8 --- /dev/null +++ b/src/utils/css-transition/index.ts @@ -0,0 +1,3 @@ +export { default as CSSTransition } from './CSSTransition' +export { default as Transition } from './Transition' +export { default as config } from './config' diff --git a/src/utils/css-transition/utils/PropTypes.js b/src/utils/css-transition/utils/PropTypes.js new file mode 100644 index 0000000000..a38b6b73c7 --- /dev/null +++ b/src/utils/css-transition/utils/PropTypes.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types' + +export const timeoutsShape = + process.env.NODE_ENV !== 'production' + ? PropTypes.oneOfType([ + PropTypes.number, + PropTypes.shape({ + enter: PropTypes.number, + exit: PropTypes.number, + appear: PropTypes.number, + }).isRequired, + ]) + : null + +export const classNamesShape = + process.env.NODE_ENV !== 'production' + ? PropTypes.oneOfType([ + PropTypes.string, + PropTypes.shape({ + enter: PropTypes.string, + exit: PropTypes.string, + active: PropTypes.string, + }), + PropTypes.shape({ + enter: PropTypes.string, + enterDone: PropTypes.string, + enterActive: PropTypes.string, + exit: PropTypes.string, + exitDone: PropTypes.string, + exitActive: PropTypes.string, + }), + ]) + : null diff --git a/src/utils/css-transition/utils/reflow.ts b/src/utils/css-transition/utils/reflow.ts new file mode 100644 index 0000000000..718f4ced42 --- /dev/null +++ b/src/utils/css-transition/utils/reflow.ts @@ -0,0 +1 @@ +export const forceReflow = (node: HTMLElement) => node.scrollTop