From: Evan You Date: Thu, 28 Nov 2019 23:41:01 +0000 (-0500) Subject: feat(transition): TransitionGroup X-Git-Tag: v3.0.0-alpha.0~130 X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=800b0f0e7a1176f630efed877251205968c6f934;p=thirdparty%2Fvuejs%2Fcore.git feat(transition): TransitionGroup --- diff --git a/packages/runtime-core/src/components/BaseTransition.ts b/packages/runtime-core/src/components/BaseTransition.ts index 0513cdb1a1..7fa736aea5 100644 --- a/packages/runtime-core/src/components/BaseTransition.ts +++ b/packages/runtime-core/src/components/BaseTransition.ts @@ -1,7 +1,8 @@ import { getCurrentInstance, SetupContext, - ComponentOptions + ComponentOptions, + ComponentInternalInstance } from '../component' import { cloneVNode, @@ -61,9 +62,9 @@ type TransitionHookCaller = ( args?: any[] ) => void -type PendingCallback = (cancelled?: boolean) => void +export type PendingCallback = (cancelled?: boolean) => void -interface TransitionState { +export interface TransitionState { isMounted: boolean isLeaving: boolean isUnmounting: boolean @@ -72,7 +73,7 @@ interface TransitionState { leavingVNodes: Map> } -interface TransitionElement { +export interface TransitionElement { // in persisted mode (e.g. v-show), the same element is toggled, so the // pending enter/leave callbacks may need to cancalled if the state is toggled // before it finishes. @@ -80,32 +81,27 @@ interface TransitionElement { _leaveCb?: PendingCallback } +export function useTransitionState(): TransitionState { + const state: TransitionState = { + isMounted: false, + isLeaving: false, + isUnmounting: false, + leavingVNodes: new Map() + } + onMounted(() => { + state.isMounted = true + }) + onBeforeUnmount(() => { + state.isUnmounting = true + }) + return state +} + const BaseTransitionImpl = { name: `BaseTransition`, setup(props: BaseTransitionProps, { slots }: SetupContext) { const instance = getCurrentInstance()! - const state: TransitionState = { - isMounted: false, - isLeaving: false, - isUnmounting: false, - leavingVNodes: new Map() - } - onMounted(() => { - state.isMounted = true - }) - onBeforeUnmount(() => { - state.isUnmounting = true - }) - - const callTransitionHook: TransitionHookCaller = (hook, args) => { - hook && - callWithAsyncErrorHandling( - hook, - instance, - ErrorCodes.TRANSITION_HOOK, - args - ) - } + const state = useTransitionState() return () => { const children = slots.default && slots.default() @@ -147,7 +143,7 @@ const BaseTransitionImpl = { innerChild, rawProps, state, - callTransitionHook + instance )) const oldChild = instance.subTree @@ -163,7 +159,7 @@ const BaseTransitionImpl = { oldInnerChild, rawProps, state, - callTransitionHook + instance ) // update old tree's hooks in case of dynamic transition setTransitionHooks(oldInnerChild, leavingHooks) @@ -245,7 +241,7 @@ function getLeavingNodesForType( // The transition hooks are attached to the vnode as vnode.transition // and will be called at appropriate timing in the renderer. -function resolveTransitionHooks( +export function resolveTransitionHooks( vnode: VNode, { appear, @@ -260,11 +256,21 @@ function resolveTransitionHooks( onLeaveCancelled }: BaseTransitionProps, state: TransitionState, - callHook: TransitionHookCaller + instance: ComponentInternalInstance ): TransitionHooks { const key = String(vnode.key) const leavingVNodesCache = getLeavingNodesForType(state, vnode) + const callHook: TransitionHookCaller = (hook, args) => { + hook && + callWithAsyncErrorHandling( + hook, + instance, + ErrorCodes.TRANSITION_HOOK, + args + ) + } + const hooks: TransitionHooks = { persisted, beforeEnter(el: TransitionElement) { diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 88831de37d..019b081e89 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -58,6 +58,13 @@ export { callWithErrorHandling, callWithAsyncErrorHandling } from './errorHandling' +export { + useTransitionState, + TransitionState, + resolveTransitionHooks, + setTransitionHooks, + TransitionHooks +} from './components/BaseTransition' // Internal, for compiler generated code // should sync with '@vue/compiler-core/src/runtimeConstants.ts' diff --git a/packages/runtime-dom/src/components/Transition.ts b/packages/runtime-dom/src/components/Transition.ts index 18f15ddced..825802f061 100644 --- a/packages/runtime-dom/src/components/Transition.ts +++ b/packages/runtime-dom/src/components/Transition.ts @@ -58,7 +58,7 @@ if (__DEV__) { } } -function resolveTransitionProps({ +export function resolveTransitionProps({ name = 'v', type, css = true, @@ -91,7 +91,7 @@ function resolveTransitionProps({ enterToClass = appearToClass } - type Hook = (el: Element, done?: () => void) => void + type Hook = (el: HTMLElement, done?: () => void) => void const finishEnter: Hook = (el, done) => { removeTransitionClass(el, enterToClass) @@ -188,7 +188,7 @@ function validateDuration(val: unknown) { } } -export interface ElementWithTransition extends Element { +export interface ElementWithTransition extends HTMLElement { // _vtc = Vue Transition Classes. // Store the temporarily-added transition classes on the element // so that we can avoid overwriting them if the element's class is patched @@ -196,12 +196,12 @@ export interface ElementWithTransition extends Element { _vtc?: Set } -function addTransitionClass(el: ElementWithTransition, cls: string) { +export function addTransitionClass(el: ElementWithTransition, cls: string) { el.classList.add(cls) ;(el._vtc || (el._vtc = new Set())).add(cls) } -function removeTransitionClass(el: ElementWithTransition, cls: string) { +export function removeTransitionClass(el: ElementWithTransition, cls: string) { el.classList.remove(cls) if (el._vtc) { el._vtc.delete(cls) @@ -252,9 +252,10 @@ interface CSSTransitionInfo { type: typeof TRANSITION | typeof ANIMATION | null propCount: number timeout: number + hasTransform: boolean } -function getTransitionInfo( +export function getTransitionInfo( el: Element, expectedType?: TransitionProps['type'] ): CSSTransitionInfo { @@ -298,10 +299,14 @@ function getTransitionInfo( : animationDurations.length : 0 } + const hasTransform = + type === TRANSITION && + /\b(transform|all)(,|$)/.test(styles[TRANSITION + 'Property']) return { type, timeout, - propCount + propCount, + hasTransform } } diff --git a/packages/runtime-dom/src/components/TransitionGroup.ts b/packages/runtime-dom/src/components/TransitionGroup.ts new file mode 100644 index 0000000000..cc5d8f2a9e --- /dev/null +++ b/packages/runtime-dom/src/components/TransitionGroup.ts @@ -0,0 +1,180 @@ +import { + TransitionProps, + addTransitionClass, + removeTransitionClass, + ElementWithTransition, + getTransitionInfo, + resolveTransitionProps +} from './Transition' +import { + Fragment, + VNode, + Slots, + warn, + resolveTransitionHooks, + toRaw, + useTransitionState, + getCurrentInstance, + setTransitionHooks, + createVNode, + onUpdated +} from '@vue/runtime-core' + +interface Position { + top: number + left: number +} + +const positionMap = new WeakMap() +const newPositionMap = new WeakMap() + +export type TransitionGroupProps = Omit & { + tag?: string + moveClass?: string +} + +export const TransitionGroup = { + setup(props: TransitionGroupProps, { slots }: { slots: Slots }) { + const instance = getCurrentInstance()! + const state = useTransitionState() + let prevChildren: VNode[] + let children: VNode[] + let hasMove: boolean | null = null + + onUpdated(() => { + // children is guaranteed to exist after initial render + if (!prevChildren.length) { + return + } + const moveClass = props.moveClass || `${props.name || 'v'}-move` + // Check if move transition is needed. This check is cached per-instance. + hasMove = + hasMove === null + ? (hasMove = hasCSSTransform( + prevChildren[0].el, + instance.vnode.el, + moveClass + )) + : hasMove + if (!hasMove) { + return + } + + // we divide the work into three loops to avoid mixing DOM reads and writes + // in each iteration - which helps prevent layout thrashing. + prevChildren.forEach(callPendingCbs) + prevChildren.forEach(recordPosition) + const movedChildren = prevChildren.filter(applyTranslation) + + // force reflow to put everything in position + forceReflow() + + movedChildren.forEach(c => { + const el = c.el + const style = el.style + addTransitionClass(el, moveClass) + style.transform = style.WebkitTransform = style.transitionDuration = '' + const cb = (el._moveCb = (e: TransitionEvent) => { + if (e && e.target !== el) { + return + } + if (!e || /transform$/.test(e.propertyName)) { + el.removeEventListener('transitionend', cb) + el._moveCb = null + removeTransitionClass(el, moveClass) + } + }) + el.addEventListener('transitionend', cb) + }) + }) + + return () => { + const rawProps = toRaw(props) + const cssTransitionProps = resolveTransitionProps(rawProps) + const tag = rawProps.tag || Fragment + prevChildren = children + children = slots.default ? slots.default() : [] + + for (let i = 0; i < children.length; i++) { + const child = children[i] + if (child.key != null) { + setTransitionHooks( + child, + resolveTransitionHooks(child, cssTransitionProps, state, instance) + ) + } else if (__DEV__) { + warn(` children must be keyed.`) + } + } + + if (prevChildren) { + for (let i = 0; i < prevChildren.length; i++) { + const child = prevChildren[i] + setTransitionHooks( + child, + resolveTransitionHooks(child, cssTransitionProps, state, instance) + ) + positionMap.set(child, child.el.getBoundingClientRect()) + } + } + + return createVNode(tag, null, children) + } + } +} + +function callPendingCbs(c: VNode) { + if (c.el._moveCb) { + c.el._moveCb() + } + if (c.el._enterCb) { + c.el._enterCb() + } +} + +function recordPosition(c: VNode) { + newPositionMap.set(c, c.el.getBoundingClientRect()) +} + +function applyTranslation(c: VNode): VNode | undefined { + const oldPos = positionMap.get(c)! + const newPos = newPositionMap.get(c)! + const dx = oldPos.left - newPos.left + const dy = oldPos.top - newPos.top + if (dx || dy) { + const s = c.el.style + s.transform = s.WebkitTransform = `translate(${dx}px,${dy}px)` + s.transitionDuration = '0s' + return c + } +} + +// this is put in a dedicated function to avoid the line from being treeshaken +function forceReflow() { + return document.body.offsetHeight +} + +function hasCSSTransform( + el: ElementWithTransition, + root: Node, + moveClass: string +): boolean { + // Detect whether an element with the move class applied has + // CSS transitions. Since the element may be inside an entering + // transition at this very moment, we make a clone of it and remove + // all other transition classes applied to ensure only the move class + // is applied. + const clone = el.cloneNode() as HTMLElement + if (el._vtc) { + el._vtc.forEach(cls => clone.classList.remove(cls)) + } + clone.classList.add(moveClass) + clone.style.display = 'none' + const container = (root.nodeType === 1 + ? root + : root.parentNode) as HTMLElement + container.appendChild(clone) + const { hasTransform } = getTransitionInfo(clone) + container.removeChild(clone) + return hasTransform +} diff --git a/packages/runtime-dom/src/index.ts b/packages/runtime-dom/src/index.ts index 7a0ea80d5a..62885cb2fb 100644 --- a/packages/runtime-dom/src/index.ts +++ b/packages/runtime-dom/src/index.ts @@ -55,7 +55,7 @@ export const createApp = (): App => { return app } -// DOM-only runtime helpers +// DOM-only runtime directive helpers export { vModelText, vModelCheckbox, @@ -64,11 +64,14 @@ export { vModelDynamic } from './directives/vModel' export { withModifiers, withKeys } from './directives/vOn' +export { vShow } from './directives/vShow' // DOM-only components export { Transition, TransitionProps } from './components/Transition' - -export { vShow } from './directives/vShow' +export { + TransitionGroup, + TransitionGroupProps +} from './components/TransitionGroup' // re-export everything from core // h, Component, reactivity API, nextTick, flags & types