feat(transition): TransitionGroup

This commit is contained in:
Evan You 2019-11-28 18:41:01 -05:00
parent 020e109abd
commit 800b0f0e7a
5 changed files with 241 additions and 40 deletions

View File

@ -1,7 +1,8 @@
import { import {
getCurrentInstance, getCurrentInstance,
SetupContext, SetupContext,
ComponentOptions ComponentOptions,
ComponentInternalInstance
} from '../component' } from '../component'
import { import {
cloneVNode, cloneVNode,
@ -61,9 +62,9 @@ type TransitionHookCaller = (
args?: any[] args?: any[]
) => void ) => void
type PendingCallback = (cancelled?: boolean) => void export type PendingCallback = (cancelled?: boolean) => void
interface TransitionState { export interface TransitionState {
isMounted: boolean isMounted: boolean
isLeaving: boolean isLeaving: boolean
isUnmounting: boolean isUnmounting: boolean
@ -72,7 +73,7 @@ interface TransitionState {
leavingVNodes: Map<any, Record<string, VNode>> leavingVNodes: Map<any, Record<string, VNode>>
} }
interface TransitionElement { export interface TransitionElement {
// in persisted mode (e.g. v-show), the same element is toggled, so the // 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 // pending enter/leave callbacks may need to cancalled if the state is toggled
// before it finishes. // before it finishes.
@ -80,32 +81,27 @@ interface TransitionElement {
_leaveCb?: PendingCallback _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 = { const BaseTransitionImpl = {
name: `BaseTransition`, name: `BaseTransition`,
setup(props: BaseTransitionProps, { slots }: SetupContext) { setup(props: BaseTransitionProps, { slots }: SetupContext) {
const instance = getCurrentInstance()! const instance = getCurrentInstance()!
const state: TransitionState = { const state = useTransitionState()
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
)
}
return () => { return () => {
const children = slots.default && slots.default() const children = slots.default && slots.default()
@ -147,7 +143,7 @@ const BaseTransitionImpl = {
innerChild, innerChild,
rawProps, rawProps,
state, state,
callTransitionHook instance
)) ))
const oldChild = instance.subTree const oldChild = instance.subTree
@ -163,7 +159,7 @@ const BaseTransitionImpl = {
oldInnerChild, oldInnerChild,
rawProps, rawProps,
state, state,
callTransitionHook instance
) )
// update old tree's hooks in case of dynamic transition // update old tree's hooks in case of dynamic transition
setTransitionHooks(oldInnerChild, leavingHooks) setTransitionHooks(oldInnerChild, leavingHooks)
@ -245,7 +241,7 @@ function getLeavingNodesForType(
// The transition hooks are attached to the vnode as vnode.transition // The transition hooks are attached to the vnode as vnode.transition
// and will be called at appropriate timing in the renderer. // and will be called at appropriate timing in the renderer.
function resolveTransitionHooks( export function resolveTransitionHooks(
vnode: VNode, vnode: VNode,
{ {
appear, appear,
@ -260,11 +256,21 @@ function resolveTransitionHooks(
onLeaveCancelled onLeaveCancelled
}: BaseTransitionProps, }: BaseTransitionProps,
state: TransitionState, state: TransitionState,
callHook: TransitionHookCaller instance: ComponentInternalInstance
): TransitionHooks { ): TransitionHooks {
const key = String(vnode.key) const key = String(vnode.key)
const leavingVNodesCache = getLeavingNodesForType(state, vnode) const leavingVNodesCache = getLeavingNodesForType(state, vnode)
const callHook: TransitionHookCaller = (hook, args) => {
hook &&
callWithAsyncErrorHandling(
hook,
instance,
ErrorCodes.TRANSITION_HOOK,
args
)
}
const hooks: TransitionHooks = { const hooks: TransitionHooks = {
persisted, persisted,
beforeEnter(el: TransitionElement) { beforeEnter(el: TransitionElement) {

View File

@ -58,6 +58,13 @@ export {
callWithErrorHandling, callWithErrorHandling,
callWithAsyncErrorHandling callWithAsyncErrorHandling
} from './errorHandling' } from './errorHandling'
export {
useTransitionState,
TransitionState,
resolveTransitionHooks,
setTransitionHooks,
TransitionHooks
} from './components/BaseTransition'
// Internal, for compiler generated code // Internal, for compiler generated code
// should sync with '@vue/compiler-core/src/runtimeConstants.ts' // should sync with '@vue/compiler-core/src/runtimeConstants.ts'

View File

@ -58,7 +58,7 @@ if (__DEV__) {
} }
} }
function resolveTransitionProps({ export function resolveTransitionProps({
name = 'v', name = 'v',
type, type,
css = true, css = true,
@ -91,7 +91,7 @@ function resolveTransitionProps({
enterToClass = appearToClass enterToClass = appearToClass
} }
type Hook = (el: Element, done?: () => void) => void type Hook = (el: HTMLElement, done?: () => void) => void
const finishEnter: Hook = (el, done) => { const finishEnter: Hook = (el, done) => {
removeTransitionClass(el, enterToClass) 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. // _vtc = Vue Transition Classes.
// Store the temporarily-added transition classes on the element // Store the temporarily-added transition classes on the element
// so that we can avoid overwriting them if the element's class is patched // 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<string> _vtc?: Set<string>
} }
function addTransitionClass(el: ElementWithTransition, cls: string) { export function addTransitionClass(el: ElementWithTransition, cls: string) {
el.classList.add(cls) el.classList.add(cls)
;(el._vtc || (el._vtc = new Set())).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) el.classList.remove(cls)
if (el._vtc) { if (el._vtc) {
el._vtc.delete(cls) el._vtc.delete(cls)
@ -252,9 +252,10 @@ interface CSSTransitionInfo {
type: typeof TRANSITION | typeof ANIMATION | null type: typeof TRANSITION | typeof ANIMATION | null
propCount: number propCount: number
timeout: number timeout: number
hasTransform: boolean
} }
function getTransitionInfo( export function getTransitionInfo(
el: Element, el: Element,
expectedType?: TransitionProps['type'] expectedType?: TransitionProps['type']
): CSSTransitionInfo { ): CSSTransitionInfo {
@ -298,10 +299,14 @@ function getTransitionInfo(
: animationDurations.length : animationDurations.length
: 0 : 0
} }
const hasTransform =
type === TRANSITION &&
/\b(transform|all)(,|$)/.test(styles[TRANSITION + 'Property'])
return { return {
type, type,
timeout, timeout,
propCount propCount,
hasTransform
} }
} }

View File

@ -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<VNode, Position>()
const newPositionMap = new WeakMap<VNode, Position>()
export type TransitionGroupProps = Omit<TransitionProps, 'mode'> & {
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(`<TransitionGroup> 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
}

View File

@ -55,7 +55,7 @@ export const createApp = (): App<Element> => {
return app return app
} }
// DOM-only runtime helpers // DOM-only runtime directive helpers
export { export {
vModelText, vModelText,
vModelCheckbox, vModelCheckbox,
@ -64,11 +64,14 @@ export {
vModelDynamic vModelDynamic
} from './directives/vModel' } from './directives/vModel'
export { withModifiers, withKeys } from './directives/vOn' export { withModifiers, withKeys } from './directives/vOn'
export { vShow } from './directives/vShow'
// DOM-only components // DOM-only components
export { Transition, TransitionProps } from './components/Transition' export { Transition, TransitionProps } from './components/Transition'
export {
export { vShow } from './directives/vShow' TransitionGroup,
TransitionGroupProps
} from './components/TransitionGroup'
// re-export everything from core // re-export everything from core
// h, Component, reactivity API, nextTick, flags & types // h, Component, reactivity API, nextTick, flags & types