import { CreateAnimationOptions } from './interface' // #ifdef APP-IOS import { Ref } from 'vue' // #endif // import { addUnit } from '@/uni_modules/lime-shared/addUnit' const defaultOption : CreateAnimationOptions = { duration: 400, timingFunction: 'linear', delay: 0, transformOrigin: '50% 50%' } function toCamelCase(str : string) : string { return str .replace(/(?:^\w|[A-Z]|\b\w)/g, (word : string, index : number, _ : string) : string => { return index == 0 ? word.toLowerCase() : word.toUpperCase(); }) .replace(/[-_\s]+/g, ''); } function camelToKebabCase(str : string) : string { return str .replace(/([a-z0-9])([A-Z])/g, '$1-$2') .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') .toLowerCase(); } function addUnit(value : any) : any { const regex = new RegExp("^(-)?\\d+(\\.\\d+)?$") const numberType = ['number', 'Int', 'UInt', 'Long', 'Float', 'Double'] if (typeof value == 'string' && regex.test(value as string)) { return `${value}px` } else if (numberType.includes(typeof value)) { return `${value}px` } return value } // #ifdef APP-IOS // #endif const transitionProperty = [ 'width', 'height', 'left', 'top', 'bottom', 'right', 'opacity', 'background-color', 'transform', 'border-color', 'border-left-color', 'border-top-color', 'border-right-color', 'border-bottom-color', 'margin', 'margin-left', 'margin-top', 'margin-right', 'margin-bottom', 'padding', 'padding-left', 'padding-top', 'padding-right', 'padding-bottom', // #ifdef WEB 'skew', 'skewX', 'skewY', 'matrix', 'matrix3d' //'perspective', // #endif ] const transitionSettings = ['transition-delay', 'transition-duration', 'transition-timing-function', 'transform-origin'] /** * Step 类表示一个动画步骤 */ class Step { styles : Map transform : Map options : CreateAnimationOptions = {} as CreateAnimationOptions root : Step | null = null prev : Step | null = null next : Step | null = null stepIndex : number = 0 currentStepIndex : number = 0 stepCount : number = 1 loop : number = 1 loopCount : number = 1 isPlaying : boolean = false timer : number = 0 onFinish : () => void = () => { } onUpdate : () => void = () => { } /** * 构造函数 * @param options 动画选项 * @param stepIndex 步骤索引 */ constructor(options : CreateAnimationOptions, stepIndex : number = 0) { this.styles = new Map() this.transform = new Map() this.stepIndex = stepIndex; this.updateOption(options) } /** * 更新动画选项 * @param options 动画选项 */ updateOption(options : CreateAnimationOptions) { for (let key in options) { const item = options[key] this.options[key] = item ?? defaultOption[key] } } /** * 添加一个新的动画步骤 * @param options 动画选项 * @returns 新的动画步骤 */ addStep(options : CreateAnimationOptions) : Step { if (this.next == null) { const root = this.root ?? this; this.next = new Step(options, this.stepIndex + 1); this.next!.prev = this; this.next!.root = root; return this.next! } else { return this.next!.addStep(options); } } /** * 停止动画 */ stop() { const root = this.root ?? this root.isPlaying = false clearTimeout(root.timer) } /** * 等待指定时间 * @param time 等待时间 * @returns Promise */ private sleep(time : number = 0) : Promise { return new Promise((resolve) => { setTimeout(function () { resolve(true) }, time); }) } /** * 播放动画 * @param element 动画元素 */ async play(element : UniElement, stepIndex : number = -1) { if (stepIndex > -1) { this.getStepByIndex(stepIndex)?.play(element) return } const duration = this.options.duration ?? 1 const delay = this.options.delay ?? 0; const root = this.root ?? this root.isPlaying = true await this.sleep(this.stepIndex == 0 ? 100 : 0) // element.offsetHeight; for (let key in this.options) { const name = key === 'transformOrigin' ? key : toCamelCase(`transition-${key}`) let value = this.options[key] if (['delay', 'duration'].includes(key) && ['number', 'Int'].includes(typeof value)) { value = `${value}ms` } element.style.setProperty(camelToKebabCase(name), value) } // element.style.setProperty('transition-property', 'all') // await this.sleep() // element.offsetHeight let transformStr = ((element.style.getPropertyValue('transform') ?? '') as string).replace('none','') this.transform.forEach((value, key) => { const regex = new RegExp(`${key}\\([^)]*\\)`); if (regex.test(transformStr)) { // 如果存在,则修改 key 的 value transformStr = transformStr.replace(regex, `${key}(${value})`); } else { transformStr += ` ${key}(${value})` } }) this.styles.forEach((value, key) => { element.style.setProperty(key, value) }) element.style.setProperty('transform', transformStr) if (this.next != null && this.next!.styles.size != 0) { root.currentStepIndex = this.next!.stepIndex // await this.sleep(duration + delay) root.timer = setTimeout(() => { this.next?.play(element) }, duration + delay) } else if (root.loop == -1 || root.loopCount < root.loop) { // await this.sleep(duration + delay) root.currentStepIndex = 0 root.timer = setTimeout(() => { root.onUpdate() root.loopCount++ root.play(element) }, duration + delay) } else { let callbackWrapper : UniCallbackWrapper | null = null const transitionend = (_ : UniEvent) => { root.isPlaying = false root.loopCount = 1 root.currentStepIndex = 0 root.onFinish(); // #ifdef WEB element.removeEventListener('transitionend', transitionend) // #endif // #ifdef APP if (callbackWrapper == null) return element.removeEventListener('transitionend', callbackWrapper!) // #endif } callbackWrapper = element.addEventListener('transitionend', transitionend) } } /** * 获取第一个步骤 * @returns 第一个步骤 */ getFirstStep() : Step | null { if (this.prev == null) { return this; } return this.prev!.getFirstStep(); } /** * 获取最后一个步骤 * @returns 最后一个步骤 */ getLastStep() : Step | null { if (this.next == null) { return this; } return this.next!.getLastStep(); } /** * 根据索引获取步骤 * @param index 步骤索引 * @returns 对应索引的步骤 */ getStepByIndex(index : number) : Step | null { if (this.stepIndex === index) { return this; } if (this.next != null && this.stepIndex < index) { return this.next!.getStepByIndex(index); } if (this.prev != null && this.stepIndex > index) { return this.prev!.getStepByIndex(index); } return null; } } /** * Animation 类 */ class Animation { private options : CreateAnimationOptions = {} as CreateAnimationOptions private steps : Step | null private _steps : Step | null = null private currentStep : Step | null private element : UniElement | null = null private cacheStyles : Map = new Map() onFinish : () => void = () => { } constructor(options : CreateAnimationOptions) { this.updateOption(options) this.steps = new Step(this.options) this.steps!.onFinish = () => { this.onFinish() } this.currentStep = this.steps // #ifdef WEB try { const directions = ['top', 'left', 'right', 'bottom']; directions.forEach((key) => { const name = `--lime-ani-${key}`; window.CSS.registerProperty({ name, syntax: '', inherits: false, initialValue: '0' }) }) } catch (error) { if (error instanceof DOMException) { // console.error(`Failed to register property`); } else { throw error; } } // #endif } /** * 更新选项 * @param options 创建动画选项 */ private updateOption(options : CreateAnimationOptions) { for (let key in options) { const item = options[key] this.options[key] = item ?? defaultOption[key] } } /** * 更新 transform 属性 * @param property transform 属性名 * @param value transform 属性值 */ private updateTransform(property : string, value : string) { // 设置更新后的 transform 属性值 this.currentStep?.styles!.set('transform', ''); this.currentStep?.transform!.set(property, value) } /** * 设置透明度 * @param value 透明度值 * @returns Animation 实例 */ opacity(value : number) : Animation { this.currentStep?.styles!.set('opacity', value) return this } /** * 设置背景颜色 * @param color 颜色值 * @returns Animation 实例 */ backgroundColor(color : string) : Animation { this.currentStep?.styles!.set('backgroundColor', color) return this } width(length : any) : Animation { return this.add('width', length) } height(length : any) : Animation { return this.add('height', length) } top(length : any) : Animation { return this.add('top', length) } left(length : any) : Animation { return this.add('left', length) } right(length : any) : Animation { return this.add('right', length) } bottom(length : any) : Animation { return this.add('bottom', length) } rotateX(deg : number) : Animation { this.updateTransform('rotateX', `${deg}deg`); return this } rotateY(deg : number) : Animation { this.updateTransform('rotateY', `${deg}deg`); return this } rotateZ(deg : number) : Animation { this.updateTransform('rotateZ', `${deg}deg`); return this } rotate(deg : number) : Animation { this.updateTransform('rotate', `${deg}deg`); return this } scaleX(number : number) : Animation { this.updateTransform('scaleX', `${number.toString()}`) return this } scaleY(number : number) : Animation { this.updateTransform('scaleY', `${number.toString()}`) return this } scaleZ(number : number) : Animation { this.updateTransform('scaleZ', `${number.toString()}`) return this } scale(scaleX : number) : Animation scale(scaleX : number, scaleY : number = 0) : Animation { const _scaleY = scaleY == 0 ? scaleX : scaleY; this.updateTransform('scale', `${scaleX},${_scaleY}`); return this } // #ifdef WEB skewX(number : number) : Animation { this.updateTransform('skewX', `${number.toString()}deg`) return this } skewY(number : number) : Animation { this.updateTransform('skewY', `${number.toString()}deg`) return this } skew(skewX : number) : Animation skew(skewX : number, skewY : number = 0) : Animation { const _skewY = skewY == 0 ? skewX : skewY; this.updateTransform('skew', `${skewX}deg,${_skewY}deg`); return this } //matrix3d matrix(a : number, b : number, c : number, d : number, tx : number, ty : number) : Animation { this.updateTransform('skew', `${a},${b},${c},${d},${tx},${ty}`); return this } // #endif translateX(length : any) : Animation { const value = addUnit(length) this.updateTransform('translateX', `${value}`) return this } translateY(length : any) : Animation { const value = addUnit(length) this.updateTransform('translateY', `${value}`) return this } translate(x : any, y : any) : Animation { const _x = addUnit(x) const _y = addUnit(y) this.updateTransform('translate', `${_x},${_y}`); return this } margin(value : any) : Animation { if (typeof value == 'string') { return this.add('margin', value) } else if (Array.isArray(value)) { return this.add('margin', (value as any[]).join(' ')) } return this } marginLeft(value : any) : Animation { return this.add('marginLeft', value) } marginRight(value : any) : Animation { return this.add('marginRight', value) } marginTop(value : any) : Animation { return this.add('marginTop', value) } marginBottom(value : any) : Animation { return this.add('marginBottom', value) } padding(value : any) : Animation { if (typeof value == 'string') { return this.add('padding', value) } else if (Array.isArray(value)) { return this.add('padding', (value as any[]).join(' ')) } return this } paddingLeft(value : any) : Animation { return this.add('paddingLeft', value) } paddingRight(value : any) : Animation { return this.add('paddingRight', value) } paddingTop(value : any) : Animation { return this.add('paddingTop', value) } paddingBottom(value : any) : Animation { return this.add('paddingBottom', value) } borderColor(color : string) : Animation { return this.add('borderColor', color) } borderTopColor(color : string) : Animation { return this.add('borderTopColor', color) } borderBottomColor(color : string) : Animation { return this.add('borderBottomColor', color) } borderLeftColor(color : string) : Animation { return this.add('borderLeftColor', color) } borderRightColor(color : string) : Animation { return this.add('borderRightColor', color) } /** * 添加动画属性 * @param type 动画属性类型 * @param value 动画属性值 * @returns Animation 实例 */ add(type : string, value : any) : Animation { const _v = addUnit(value) if (['opacity', 'transform', 'background-color', 'backgroundColor'].includes(type)) { this.currentStep?.styles!.set(type, value) } else { this.currentStep?.styles!.set(type, _v) } return this } step(options : UTSJSONObject | null = null) : Animation { if (options != null) { const _options = {} as CreateAnimationOptions for (let key in this.options) { const value = options[key] if (value != null) { _options[key] = value } } this.currentStep?.updateOption(_options) } this.currentStep = this.currentStep?.addStep(this.options) return this } /** * 设置初始样式 * @param element 元素 */ private setCacheStyles(element : UniElement) { // @ts-ignore // #ifndef APP-IOS // element.style.cssText.split(';').forEach(style => { // if (style.includes(':')) { // const [property, value] = style.split(':'); // if(!transitionProperty.includes(property)){ // if(property == 'backgroundColor'){ // this.cacheStyles.set(property, value) // } // } // } // }) // #endif transitionProperty.forEach(property => { const value = this.getCSSValue(element, property) //element.style.getPropertyValue(property) if (value != null) { this.cacheStyles.set(property, value) } }) transitionSettings.forEach(property => { const value = this.getCSSValue(element, property) //element.style.getPropertyValue(property) if (value != null) { this.cacheStyles.set(property, value) } }) // this.cacheStyles.set('transform', '') } getCSSValue(el : UniElement, prop : string):any|null { const uppercasePropName = prop.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); // #ifdef WEB // @ts-ignore const style = getComputedStyle(el) ?? el.style const value = style.getPropertyValue(uppercasePropName) || style[uppercasePropName] // #endif // #ifndef WEB const value = el.style.getPropertyValue(uppercasePropName) // #endif return value } /** * 重置样式 * @param element 元素 */ private resetStyles(element : UniElement) { // @ts-ignore // #ifndef APP-IOS // element.style.cssText.split(';').forEach(style => { // if (style.includes(':')) { // const property = style.split(':')[0].trim(); // if (property == 'transform-origin') { // element.style.setProperty(property, '50% 50%') // } else if (property == 'transition-duration') { // element.style.setProperty(property, '1ms') // } else { // element.style.setProperty(property, this.cacheStyles.get(property) ?? '') // } // } // }) // #endif const directions = ['top', 'left', 'right', 'bottom']; transitionSettings.forEach(property => { const value = this.cacheStyles.get(property) if (property == 'transition-duration') { element.style.setProperty(property, value ?? '0ms') } else if (value != null) { element.style.setProperty(property, value) } }) transitionProperty.forEach((property) => { const value = this.cacheStyles.get(property) if (value != null || directions.includes(property) || property == 'transform') { element.style.setProperty(property, value ?? '') } }) element.style.setProperty('transition-property', transitionProperty.join(',')) // #ifdef WEB directions.forEach(key => { element.style.setProperty(`${key}`, `var(--lime-ani-${key})`) }) // #endif element.getBoundingClientRect() } export(element : UniElement, loopCount : number) : UTSJSONObject export(element : Ref, loopCount : number) : UTSJSONObject export(element : any | null = null, loopCount : number = 1) : UTSJSONObject { this._steps = this.steps this.steps = new Step(this.options) this.currentStep = this.steps this.steps!.onFinish = () => { this.onFinish() } // @ts-ignore this.play(element, loopCount) return {} } /** * 播放动画 * @param element 元素,可选 */ play() : void play(element : UniElement, loopCount : number) : void play(element : Ref, loopCount : number) : void play(element : any | null = null, loopCount : number | null = null) : void { if (this._steps != null && this._steps!.isPlaying) return const _loopCount = loopCount ?? this._steps?.loop ?? 1 if (element == null) { this._play(null, _loopCount) } else if (element instanceof UniElement) { this._play(element, _loopCount) } else { // #ifdef APP-ANDROID if (!(element instanceof Ref)) return // #endif // @ts-ignore watch(element, (el : UniElement) => { this._play(el, _loopCount) }) } } private _play(element : UniElement | null, loopCount : number = 1) { if (this.element == null && element != null) { this.element = element this.setCacheStyles(this.element!) } if (this.element == null || this._steps == null || this._steps!.isPlaying) return if (this._steps!.currentStepIndex == 0) { this.resetStyles(this.element!) } this._steps!.loop = loopCount this._steps!.onUpdate = () => { this.resetStyles(this.element!) } this._steps!.play(this.element!, this._steps!.currentStepIndex) } /** * 停止动画 */ stop() { this._steps?.stop() } /** * 销毁动画 */ destroy() { this.stop(); this.element = null; this.steps = null; this._steps = null; this.currentStep = null; this.cacheStyles.clear(); } } export function createAnimation(options : CreateAnimationOptions = {} as CreateAnimationOptions) : Animation { // #ifdef APP||WEB return new Animation(options) // #endif // #ifndef APP||WEB // @ts-ignore return uni.createAnimation({ ...(options ?? {}) }) // #endif }