关于动画特效的一种简单实现及思考
设计思考&理念:
业务无关
- 尽量不入侵正常业务,即使有兼容问题也不影响主流程
框架无关
- 纯 js+css3 原生实现,不挑框架
需求背景:
有这样一个需求,某个功能控件首次消失时给一个收起到其唤起按钮位置的动效,目的是提示用户在哪里可以再次唤起该功能。
如下:
需求分析:
分析:
- 只有用户第一次收起需要有此动效,其余场景保持原逻辑(立即消失)。
- 功能控件作为业务逻辑的一部分,不应该把首次消失动画这种提示类的辅助性逻辑耦合到功能控件正常逻辑中。
- 细想其实这就是新功能引导,和平时我们加的新功能引导气泡类似。
结论:
- 所以该功能的实现应该与业务场景无关
- 且是可以抽取为通用的一种utils方法
技术方案:
通过上面需求分析,我们需要在第一次触发控件消失时,将当前dom以缩放动画效果移动到目标dom位置。
我们现在都基于React或Vue等框架开发,可能第一时间想到的就是利用框架: 编写状态变化逻辑 => 驱动视图更新。
但其实这些mvvm框架擅长的是书写业务逻辑,而是擅长于实现动画特性,原因:
- 这种动画效果,框架实现需要通过变更状态频繁执行diff、render等逻辑来更新视图,比较消耗性能。
- 动画特性功能实现一般得具备通用性,最好与框架无关,方便移植。
在很久以前原生js实现动画还是比较麻烦的,但css3普及之后,可以利用transition、 animation等特性更便捷地进行动画实现,故最终选用原生 js + css3 实现。
实现过程
- 需求分析 --> 结论是实现过程要与业务无关
- 技术方案 --> 结论是用原生 js + css3 实现
程序流程:在控件消失前事件(需要暴露事件,eg:beforeClose)回调中,判断是第一次(根据需求,eg:浏览storage记录)调用添加动效的方法。
入侵业务流程示意:
不入侵主流程示意:
由于不能依赖主业务流程,所以不能直接在原Dom(触发取消时,就会被移除了)上执行动画,需要copy一个样式相同的Dom执行动画效果(防止被覆盖,该copyDom会append到body下)。
这里由于是缩放效果,考虑使用transform的scale特性,通过transform-origin设置缩放中心点位置实现效果。
show me the code:
js最小实现如下
function zoomOutToDom(
targetDom,
toDom,
opt
) {
if (!targetDom || !toDom) {
console.warn('zoomOutToDom targetDom or toDom is null.');
return;
}
try {
const {left, top, width} = targetDom.getBoundingClientRect();
const {left: toLeft, top: toTop, width: toWidth, height: toHeight} = toDom.getBoundingClientRect();
const {
ghostClassName // 可以给copy出的dom添加class 自定义样式or修复样式。
} = opt || {};
const copyDom = targetDom.cloneNode(true);
ghostClassName && copyDom.classList.add(ghostClassName);
const xPos = toLeft + toWidth / 2 - left;
const yPos = toTop + toHeight / 2 - top;
const targetDomstyle = {
width: `${width}px`,
position: 'fixed',
zIndex: '99999',
left: `${left}px`,
top: `${top}px`,
transition: `all 0.8s ease-out`,
'transform-origin': `${xPos}px ${yPos}px`
};
Object.assign(copyDom.style, targetDomstyle);
const run = function () {
document.body.appendChild(copyDom);
copyDom.addEventListener('transitionend', (evt) => {
// 由于 transition all,故transitionend事件会触发三次,每个属性(opacity transform transitionend)变化都会触发
console.log(evt, '--- transitionend zoomOutToDom ---');
// 动画结束 删除dom
if (copyDom && document.contains(copyDom)) {
copyDom.remove();
onEnd && onEnd();
}
}, false);
const toStyle = {
opacity: '0.6',
transform: 'scale(0)',
};
// 重要!! append到body下之后,下一帧css变化才能触发transition效果
requestAnimationFrame(() => {
Object.assign(copyDom.style, toStyle);
});
};
run();
} catch (err) {
console.error(err, '--- from zoomOutToDom');
}
}
可以把上面代码贴到浏览器控制台,然后通过下面代码选取两个dom进行测试。
注意选取的targetDom需要有独立样式,不依赖父级元素的css选择器控制的样式,否则可能需要传入ghostClassName对copyDom进行样式修复。
// 按需替换
zoomOutToDom(document.querySelector('xxx-a'), document.querySelector('xxx-b'));
由于业务无关性,独立于主流程运行,即使浏览器兼容性问题导致动效执行失败或报错了也不影响用户正常使用系统,优雅降级。
最终实现:
考虑通用性和拓展性,最后相对完整的TS实现如下:
/**
* @file 简单实现一个DOM逐渐收起到某个DOM位置的动画。
*/
import {debounce} from 'lodash';
export interface ZoomOption {
ghostClassName?: string;
opacity?: number; // 0-1 to state 's opacity
zIndex?: number; // 动画浮层dom的层级
offsetX?: number; // 目的位置(toDom中心点)x轴偏移
offsetY?: number; // 目的位置(toDom中心点)y轴偏移
duration?: number; // 单位 s
timingFunction?: string; // linear, ease, ease-in
onStart?: () => void; // 动画开始事件
onEnd?: () => void; // 动画结束事件
onCancel?: () => void; // 动画取消事件
autoTrigger?: boolean; // 是否创建后立即自动执行
}
export interface ZoomOutHandle {
(): void;
cancel: () => void;
}
const noopZoomOut = function () {} as ZoomOutHandle;
const noop = () => {};
Object.assign(noopZoomOut, {
cancel: noop
});
export function zoomOutToDom(
targetDom: HTMLElement,
toDom: HTMLElement,
opt?: ZoomOption
): ZoomOutHandle {
if (!targetDom || !toDom) {
console.warn('zoomOutToDom targetDom or toDom is null.');
return noopZoomOut;
}
try {
const {left, top, width} = targetDom.getBoundingClientRect();
const {left: toLeft, top: toTop, width: toWidth, height: toHeight} = toDom.getBoundingClientRect();
const {
duration = 0.8,
opacity = 0.6,
zIndex = 99999,
offsetX = 0,
offsetY = 0,
ghostClassName,
timingFunction = 'ease-in',
onStart,
onEnd,
onCancel,
autoTrigger = true
} = opt || {};
const copyDom = targetDom.cloneNode(true) as HTMLElement;
ghostClassName && copyDom.classList.add(ghostClassName);
const xPos = toLeft + toWidth / 2 - left + offsetX;
const yPos = toTop + toHeight / 2 - top + offsetY;
const targetDomstyle: Partial<CSSStyleDeclaration> = {
width: `${width}px`,
position: 'fixed',
zIndex: `${zIndex}`,
left: `${left}px`,
top: `${top}px`,
transition: `all ${duration}s ${timingFunction}`,
'transform-origin': `${xPos}px ${yPos}px`
willChange: 'transform,scale,left,top,width,heigth'
};
Object.assign(copyDom.style, targetDomstyle);
const handleStart = debounce(
() => {
console.log('zoomOutToDom 动画开始了');
onStart && onStart();
},
100
);
const handleEnd = debounce(
() => {
console.log('zoomOutToDom 动画结束了');
copyDom.remove();
onEnd && onEnd();
},
100
);
const handleCancel = debounce(
() => {
console.log('zoomOutToDom 动画取消了');
onCancel && onCancel();
},
100
);
const run = function () {
document.body.appendChild(copyDom);
// 多属性变化触发多次事件,need debounce
copyDom.addEventListener('transitionstart', handleStart, false);
copyDom.addEventListener('transitionend', handleEnd, false);
copyDom.addEventListener('transitioncancel', handleCancel, false);
const xPos = toLeft + toWidth / 2 - left + offsetX;
const yPos = toTop + toHeight / 2 - top + offsetY;
const toStyle = {
opacity: `${opacity}`,
transform: 'scale(0)',
};
// 重要!! append到body下之后,下一帧css变化才能触发transition效果
requestAnimationFrame(() => {
Object.assign(copyDom.style, toStyle);
});
};
if (autoTrigger) {
run();
}
run.cancel = () => {
console.log('zoomOutToDom 主动取消动画');
if (copyDom && document.contains(copyDom)) {
copyDom.remove();
}
};
return run;
} catch (err) {
console.error(err, '--- from zoomOutToDom');
return noopZoomOut;
}
}
总结:
我们在功能实现前,有必要思考
- 对需求分析归类:业务功能 or 辅助性功能
- 功能实现是否具有共性、通用性,技术方案可跳脱框架局限
转载自:https://juejin.cn/post/7255570274271412261