diff --git a/docs-vitepress/guide/rn/style.md b/docs-vitepress/guide/rn/style.md index 54935cb3c0..8b0430dcba 100644 --- a/docs-vitepress/guide/rn/style.md +++ b/docs-vitepress/guide/rn/style.md @@ -16,7 +16,7 @@ RN 样式属性和 Web/小程序中 CSS 样式属性是相交关系: - **RN 独有属性**:`tintColor`、`writingDirection` 等,CSS 不支持 -- **CSS 独有属性**:`clip-path`、`animation` 等,RN 不支持 +- **CSS 独有属性**:`clip-path` 等,RN 不支持 因此,在跨平台开发时: 1. **优先使用交集属性**:尽量使用两边都支持的样式属性 diff --git a/packages/core/__mocks__/react-native-reanimated.js b/packages/core/__mocks__/react-native-reanimated.js new file mode 100644 index 0000000000..fd5d4babfa --- /dev/null +++ b/packages/core/__mocks__/react-native-reanimated.js @@ -0,0 +1,17 @@ +const createTiming = (name) => (...args) => ({ + name, + args, + normalize () { + return { name, args } + }, + toString () { + return `${name}(${args.join(', ')})` + } +}) + +module.exports = { + steps: createTiming('steps'), + linear: createTiming('linear'), + cubicBezier: createTiming('cubic-bezier') +} + diff --git a/packages/core/__tests__/platform/parseAnimation.spec.js b/packages/core/__tests__/platform/parseAnimation.spec.js new file mode 100644 index 0000000000..96c422f2ad --- /dev/null +++ b/packages/core/__tests__/platform/parseAnimation.spec.js @@ -0,0 +1,479 @@ +/* eslint-env jest */ + +// 为依赖的 @mpxjs/utils/env 注入运行时 mode +global.__mpx_mode__ = 'ios' + +const { parseStyleAnimation, parseStyleTransition } = require('../../src/platform/builtInMixins/parseAnimation') + +describe('parseAnimation/parseTransition for Reanimated CSS API', () => { + // ===== 基础 animation 场景 ===== + test('parseStyleAnimation: simple shorthand with name, duration and timing', () => { + const style = { + animation: 'slide-in 3s ease-in' + } + + const result = parseStyleAnimation(style) + + expect(result).toEqual({ + animationName: 'slide-in', + animationDuration: '3s', + animationTimingFunction: 'ease-in' + }) + }) + + test('parseStyleAnimation: animation with sub-properties merged and overriding shorthand', () => { + const style = { + animation: 'slide-in 3s ease-in', + animationTimingFunction: 'ease-out', + animationFillMode: 'forwards' + } + + const result = parseStyleAnimation(style) + + expect(result).toEqual({ + animationName: 'slide-in', + animationDuration: '3s', + animationTimingFunction: 'ease-out', + animationFillMode: 'forwards' + }) + }) + + test('parseStyleAnimation: full shorthand with all animation properties', () => { + const style = { + animation: '3s ease-in 1s infinite reverse both running slide-in' + } + + const result = parseStyleAnimation(style) + + expect(result).toEqual({ + animationName: 'slide-in', + animationDuration: '3s', + animationTimingFunction: 'ease-in', + animationDelay: '1s', + animationIterationCount: 'infinite', + animationDirection: 'reverse', + animationFillMode: 'both', + animationPlayState: 'running' + }) + }) + + test('parseStyleAnimation: decimal duration and ms unit', () => { + const style = { + animation: '0.5s linear 200ms infinite alternate slide-in' + } + + const result = parseStyleAnimation(style) + + expect(result).toEqual({ + animationName: 'slide-in', + animationDuration: '0.5s', + animationTimingFunction: 'linear', + animationDelay: '200ms', + animationIterationCount: 'infinite', + animationDirection: 'alternate' + }) + }) + + test('parseStyleAnimation: minimal name + duration', () => { + const style = { + animation: 'bounce 2s' + } + + const result = parseStyleAnimation(style) + + expect(result).toEqual({ + animationName: 'bounce', + animationDuration: '2s' + }) + }) + + test('parseStyleAnimation: only sub-properties without shorthand', () => { + const style = { + animationName: 'slide-in', + animationDuration: '3s', + animationTimingFunction: 'ease-in' + } + + const result = parseStyleAnimation(style) + + expect(result).toEqual({ + animationName: 'slide-in', + animationDuration: '3s', + animationTimingFunction: 'ease-in' + }) + }) + + test('parseStyleAnimation: empty style returns empty result', () => { + const result = parseStyleAnimation({}) + expect(result).toEqual({}) + }) + + test('parseStyleTransition: basic shorthand + timing function merge', () => { + const style = { + transition: 'margin-right 2s,transform 1s', + transitionTimingFunction: 'ease, cubic-bezier(0.1, 0.7, 1, 0.1)' + } + + const result = parseStyleTransition(style) + + expect(result.transitionProperty).toEqual(['marginRight', 'transform']) + expect(result.transitionDuration).toEqual(['2s', '1s']) + + expect(Array.isArray(result.transitionTimingFunction)).toBe(true) + expect(result.transitionTimingFunction[0]).toBe('ease') + + const fn = result.transitionTimingFunction[1] + expect(fn).not.toBe('cubic-bezier(0.1, 0.7, 1, 0.1)') + expect(typeof fn).toBe('object') + expect(typeof fn.toString).toBe('function') + }) + + test('parseStyleTransition: steps() and linear() timing functions', () => { + const style = { + transition: 'opacity 200ms, transform 150ms', + transitionTimingFunction: 'steps(4, jump-end), linear(0, 1)' + } + + const result = parseStyleTransition(style) + + expect(result.transitionProperty).toEqual(['opacity', 'transform']) + expect(result.transitionDuration).toEqual(['200ms', '150ms']) + expect(result.transitionTimingFunction).toHaveLength(2) + + const [stepsFn, linearFn] = result.transitionTimingFunction + + expect(typeof stepsFn).toBe('object') + expect(typeof stepsFn.normalize).toBe('function') + + expect(typeof linearFn).toBe('object') + expect(typeof linearFn.normalize).toBe('function') + }) + + test('parseStyleAnimation: shorthand with duration, delay, timing function and name', () => { + const style = { + animation: '3s ease-out 5s ball-beat' + } + + const result = parseStyleAnimation(style) + + expect(result.animationName).toBe('ball-beat') + expect(result.animationDuration).toBe('3s') + expect(result.animationDelay).toBe('5s') + expect(result.animationTimingFunction).toBe('ease-out') + }) + + test('parseStyleAnimation: multiple animations and sub-property merge', () => { + const style = { + animation: '3s linear ball-beat, 3s ease-out identifier', + animationDelay: '5s, 6s' + } + + const result = parseStyleAnimation(style) + + expect(result.animationName).toEqual(['ball-beat', 'identifier']) + expect(result.animationDuration).toEqual(['3s', '3s']) + expect(result.animationDelay).toEqual(['5s', '6s']) + + expect(Array.isArray(result.animationTimingFunction)).toBe(true) + expect(result.animationTimingFunction[0]).toBe('linear') + expect(result.animationTimingFunction[1]).toBe('ease-out') + }) + + test('parseStyleAnimation: timing function with cubic-bezier/steps/linear inside shorthand', () => { + const style = { + animation: '2s cubic-bezier(0.1, 0.7, 1, 0.1) 1s fade, 1s steps(3, start) 0s jump, 500ms linear(0, 1) pulse' + } + + const result = parseStyleAnimation(style) + + expect(result.animationName).toEqual(['fade', 'jump', 'pulse']) + expect(result.animationDuration).toEqual(['2s', '1s', '500ms']) + // 第三个动画未显式 delay,应补齐为前一个值 + expect(result.animationDelay).toEqual(['1s', '0s', '0s']) + + const [bezierFn, stepsFn, linearFn] = result.animationTimingFunction + + expect(typeof bezierFn).toBe('object') + expect(typeof bezierFn.normalize).toBe('function') + + expect(typeof stepsFn).toBe('object') + expect(typeof stepsFn.normalize).toBe('function') + + expect(typeof linearFn).toBe('object') + expect(typeof linearFn.normalize).toBe('function') + }) + + test('parseStyleAnimation: s 和 ms 单位混合', () => { + const style = { + animation: '2s ease fadeIn, 150ms ease-out fadeOut' + } + + const result = parseStyleAnimation(style) + + expect(result.animationName).toEqual(['fadeIn', 'fadeOut']) + expect(result.animationDuration).toEqual(['2s', '150ms']) + expect(result.animationTimingFunction).toEqual(['ease', 'ease-out']) + }) + + // ===== 多 animation 场景 ===== + test('parseStyleAnimation: multiple animations basic', () => { + const style = { + animation: 'slide-in 3s, fade 1s' + } + + const result = parseStyleAnimation(style) + + expect(result.animationName).toEqual(['slide-in', 'fade']) + expect(result.animationDuration).toEqual(['3s', '1s']) + }) + + test('parseStyleAnimation: multiple animations with sub-properties merged', () => { + const style = { + animation: 'slide-in 3s, fade 1s', + animationTimingFunction: 'ease-in, ease-out', + animationFillMode: 'forwards' + } + + const result = parseStyleAnimation(style) + + expect(result.animationName).toEqual(['slide-in', 'fade']) + expect(result.animationDuration).toEqual(['3s', '1s']) + expect(result.animationTimingFunction).toEqual(['ease-in', 'ease-out']) + expect(result.animationFillMode).toEqual(['forwards', 'forwards']) + }) + + test('parseStyleAnimation: multiple animations, sub-props override shorthand timing function', () => { + const style = { + animation: 'slide-in 3s ease-in, fade 1s ease-out', + animationTimingFunction: 'linear, linear' + } + + const result = parseStyleAnimation(style) + + expect(result.animationName).toEqual(['slide-in', 'fade']) + expect(result.animationDuration).toEqual(['3s', '1s']) + expect(result.animationTimingFunction).toEqual(['linear', 'linear']) + }) + + test('parseStyleAnimation: single animation, extra sub-prop values are truncated', () => { + const style = { + animation: '3s ease-in 1s 2 reverse both paused ball-beat', + animationDelay: '5s, 6s' + } + + const result = parseStyleAnimation(style) + + expect(result).toEqual({ + animationName: 'ball-beat', + animationDuration: '3s', + animationTimingFunction: 'ease-in', + animationDelay: '5s', + animationIterationCount: '2', + animationDirection: 'reverse', + animationFillMode: 'both', + animationPlayState: 'paused' + }) + }) + + test('parseStyleAnimation: three animations', () => { + const style = { + animation: '1s fade, 2s slide, 3s bounce' + } + + const result = parseStyleAnimation(style) + + expect(result.animationName).toEqual(['fade', 'slide', 'bounce']) + expect(result.animationDuration).toEqual(['1s', '2s', '3s']) + }) + + // ===== timing-function & enums 场景 ===== + test('parseStyleAnimation: all timing-function keywords', () => { + const timingFunctions = ['ease', 'linear', 'ease-in', 'ease-out', 'ease-in-out', 'step-start', 'step-end'] + timingFunctions.forEach(tf => { + const result = parseStyleAnimation({ animation: `anim 1s ${tf}` }) + expect(result.animationTimingFunction).toBe(tf) + }) + }) + + test('parseStyleAnimation: all direction values', () => { + const directions = ['normal', 'reverse', 'alternate', 'alternate-reverse'] + directions.forEach(dir => { + const result = parseStyleAnimation({ animation: `anim 1s ${dir}` }) + expect(result.animationDirection).toBe(dir) + }) + }) + + test('parseStyleAnimation: all fill-mode values', () => { + const fillModes = ['none', 'forwards', 'backwards', 'both'] + fillModes.forEach(fm => { + const result = parseStyleAnimation({ animation: `anim 1s ${fm}` }) + expect(result.animationFillMode).toBe(fm) + }) + }) + + test('parseStyleAnimation: all play-state values', () => { + const playStates = ['running', 'paused'] + playStates.forEach(ps => { + const result = parseStyleAnimation({ animation: `anim 1s ${ps}` }) + expect(result.animationPlayState).toBe(ps) + }) + }) + + test('parseStyleAnimation: negative delay is kept', () => { + const result = parseStyleAnimation({ animation: 'slide-in 3s -1s' }) + expect(result).toEqual({ + animationName: 'slide-in', + animationDuration: '3s', + animationDelay: '-1s' + }) + }) + + // ===== transition 场景 ===== + test('parseStyleTransition: simple shorthand', () => { + const style = { + transition: 'opacity 3s ease-in 1s' + } + + const result = parseStyleTransition(style) + + expect(result).toEqual({ + transitionProperty: 'opacity', + transitionDuration: '3s', + transitionTimingFunction: 'ease-in', + transitionDelay: '1s' + }) + }) + + test('parseStyleTransition: minimal property + duration', () => { + const style = { + transition: 'opacity 3s' + } + + const result = parseStyleTransition(style) + + expect(result).toEqual({ + transitionProperty: 'opacity', + transitionDuration: '3s' + }) + }) + + test('parseStyleTransition: multiple transitions', () => { + const style = { + transition: 'opacity 3s, transform 0.5s ease-out' + } + + const result = parseStyleTransition(style) + + expect(result.transitionProperty).toEqual(['opacity', 'transform']) + expect(result.transitionDuration).toEqual(['3s', '0.5s']) + expect(result.transitionTimingFunction).toEqual([undefined, 'ease-out']) + }) + + test('parseStyleTransition: longhand overrides shorthand to match CSS behavior', () => { + const style = { + transition: 'margin-right 2s, transform 1s', + transitionDuration: '1s', + // transitionDelay: '1s, 0s', + transitionProperty: 'margin-left', + marginLeft: 0, + transitionTimingFunction: 'cubic-bezier(0.1, 0.7, 1, 0.1)' + } + + const result = parseStyleTransition(style) + + // 最终只对 margin-left 做过渡,其他属性的过渡被覆盖掉 + expect(result.transitionProperty).toBe('marginLeft') + expect(result.transitionDuration).toBe('1s') + + const easing = result.transitionTimingFunction + expect(typeof easing).toBe('object') + expect(typeof easing.toString).toBe('function') + expect(easing.toString()).toContain('cubic-bezier') + }) + + test('parseStyleTransition: sub-property overrides shorthand delay', () => { + const style = { + transition: 'opacity 3s ease-in 1s', + transitionDelay: '5s' + } + + const result = parseStyleTransition(style) + + expect(result).toEqual({ + transitionProperty: 'opacity', + transitionDuration: '3s', + transitionTimingFunction: 'ease-in', + transitionDelay: '5s' + }) + }) + + test('parseStyleTransition: shorthand overrides existing duration sub-property', () => { + const style = { + transitionDuration: '5s', + transition: 'opacity 3s' + } + + const result = parseStyleTransition(style) + + expect(result).toEqual({ + transitionProperty: 'opacity', + transitionDuration: '3s' + }) + }) + + test('parseStyleTransition: only sub-properties', () => { + const style = { + transitionProperty: 'opacity', + transitionDuration: '3s', + transitionTimingFunction: 'ease-in' + } + + const result = parseStyleTransition(style) + + expect(result).toEqual({ + transitionProperty: 'opacity', + transitionDuration: '3s', + transitionTimingFunction: 'ease-in' + }) + }) + + test('parseStyleTransition: empty style returns empty result', () => { + const result = parseStyleTransition({}) + expect(result).toEqual({}) + }) + + test('parseStyleTransition: negative delay is kept', () => { + const result = parseStyleTransition({ transition: 'opacity 3s -500ms' }) + expect(result).toEqual({ + transitionProperty: 'opacity', + transitionDuration: '3s', + transitionDelay: '-500ms' + }) + }) + + // ===== 错误场景 ===== + test('parseStyleAnimation: non-object arguments throw', () => { + const invalidValues = ['slide-in 3s', null, undefined, ['slide-in 3s']] + invalidValues.forEach(v => { + expect(() => parseStyleAnimation(v)).toThrow('parseStyleAnimation 参数必须是对象') + }) + }) + + test('parseStyleTransition: non-object arguments throw', () => { + const invalidValues = ['opacity 3s', null, undefined, ['opacity 3s']] + invalidValues.forEach(v => { + expect(() => parseStyleTransition(v)).toThrow('parseStyleTransition 参数必须是对象') + }) + }) + + test('parseStyleAnimation: missing name or duration throws', () => { + expect(() => parseStyleAnimation({ animation: '3s ease-in' })).toThrow('缺少必需属性 animationName') + expect(() => parseStyleAnimation({ animation: 'slide-in' })).toThrow('缺少必需属性 animationDuration') + }) + + test('parseStyleTransition: missing property or duration throws', () => { + expect(() => parseStyleTransition({ transition: '3s ease-in' })).toThrow('缺少必需属性 transitionProperty') + expect(() => parseStyleTransition({ transition: 'opacity' })).toThrow('缺少必需属性 transitionDuration') + }) +}) + diff --git a/packages/core/src/platform/builtInMixins/parseAnimation.js b/packages/core/src/platform/builtInMixins/parseAnimation.js new file mode 100644 index 0000000000..2850585104 --- /dev/null +++ b/packages/core/src/platform/builtInMixins/parseAnimation.js @@ -0,0 +1,349 @@ +import { hump2dash, dash2hump, isString } from '@mpxjs/utils' +import { steps, linear, cubicBezier } from 'react-native-reanimated' + +/** + * 解析 style 对象中的 animation 属性,转换为子属性并合并 + * + * animation简写格式: name duration timing-function delay iteration-count direction fill-mode play-state + * 支持逗号分隔的多个动画 + * + * @param {Object} style - CSS style 对象 + * @returns {Object} 合并后的 animation 子属性对象 + * @throws {Error} 当缺少 animation-name 或 animation-duration 时抛出错误 + */ +export function parseStyleAnimation(style) { + return parseAnimation(style, 'animation') +} +/** + * 解析 style 对象中的 transition 属性,转换为子属性并合并 + * + * transition简写格式: property duration timing-function delay + * 支持逗号分隔的多个过渡 + * + * @param {Object} style - CSS style 对象 + * @returns {Object} 合并后的 transition 子属性对象 + * @throws {Error} 当缺少 transition-property 或 transition-duration 时抛出错误 + */ +export function parseStyleTransition(style) { + return parseAnimation(style, 'transition') +} + +function parseAnimation(style, keywords = 'animation') { + if (!style || typeof style !== 'object' || Array.isArray(style)) { + const fnName = keywords === 'transition' ? 'parseStyleTransition' : 'parseStyleAnimation' + throw new Error(`${fnName} 参数必须是对象`) + } + const result = {} + + // 按照对象属性顺序处理,后面的覆盖前面的 + for (const [prop, value] of Object.entries(style)) { + if (prop === keywords) { + // 解析 animation 简写属性 + const animationGroups = parseValues(value, ',') + + animationGroups.forEach((group, index) => { + const parsed = keywords === 'transition' ? parseSingleTransition(group) : keywords === 'animation' ? parseSingleAnimation(group) : {} + + for (const [p, v] of Object.entries(parsed)) { + if (!result[p]) { + result[p] = [] + } + result[p][index] = v + } + }) + } else if (prop.startsWith(keywords)) { + // 处理子属性,覆盖简写解析的值 + const values = Array.isArray(value) ? value : parseValues(value, ',') + result[prop] = [] + values.forEach((v, i) => { + if (TIMING_FUNCTIONS_EXP.test(v)) { + v = formatTimingFunction(v) + } + if (prop === 'transitionProperty') { + // transitionProperty value 转驼峰 + v = dash2hump(v) + } + result[prop][i] = v + }) + } + } + + // 以 animation-name 的长度为标准处理其他属性 + const nameCount = result[`${keywords === 'transition' ? 'transitionProperty' : 'animationName'}`]?.length || 0 + if (nameCount > 0) { + for (const [prop, value] of Object.entries(result)) { + if (Array.isArray(value) && value.length !== nameCount) { + if (value.length < nameCount) { + // 补齐:用最后一个值填充 + const lastValue = value[value.length - 1] + while (value.length < nameCount) { + value.push(lastValue) + } + } else { + // 截断到 nameCount 长度 + result[prop] = value.slice(0, nameCount) + } + } + } + } + + // 将单元素数组转为单值 + for (const [prop, value] of Object.entries(result)) { + if (Array.isArray(value) && value.length === 1) { + result[prop] = value[0] + } + } + + return result +} + +/** + * 解析单个animation值 + * @param {string} animationStr - 单个animation字符串 + * @returns {Object} 解析后的属性对象 + */ +function parseSingleAnimation(animationStr) { + const result = {} + const values = parseValues(animationStr, ' ') + const timeValues = [] + + for (const val of values) { + // 1. timing-function + if (isTimingFunction(val)) { + result.animationTimingFunction = TIMING_FUNCTIONS_EXP.test(val) ? formatTimingFunction(val) : val + continue + } + + // 2. 时间值 (duration 或 delay) + if (isTime(val)) { + timeValues.push(val) + continue + } + + // 3. iteration-count + if (isIterationCount(val)) { + result.animationIterationCount = val + continue + } + + // 4. direction + if (isDirection(val)) { + result.animationDirection = val + continue + } + + // 5. fill-mode + if (isFillMode(val)) { + result.animationFillMode = val + continue + } + + // 6. play-state + if (isPlayState(val)) { + result.animationPlayState = val + continue + } + + // 7. animation-name + if (!result.animationName) { + result.animationName = val + } + } + + // 处理时间值:第一个是 duration,第二个是 delay + if (timeValues.length >= 1) { + result.animationDuration = timeValues[0] + } + if (timeValues.length >= 2) { + result.animationDelay = timeValues[1] + } + + // 校验必需属性 + if (!result.animationName) { + throw new Error('animation 缺少必需属性 animationName') + } + if (!result.animationDuration) { + throw new Error(`动画 "${result.animationName}" 缺少必需属性 animationDuration`) + } + + return result +} + +/** + * 解析单个 transition 值 + * @param {string} transitionStr - 单个 transition 字符串 + * @returns {Object} 解析后的属性对象 + */ +function parseSingleTransition(transitionStr) { + const result = {} + const values = parseValues(transitionStr, ' ') + const timeValues = [] + + for (const val of values) { + // 1. timing-function + if (isTimingFunction(val)) { + result.transitionTimingFunction = TIMING_FUNCTIONS_EXP.test(val) ? formatTimingFunction(val) : val + continue + } + + // 2. 时间值 (duration 或 delay) + if (isTime(val)) { + timeValues.push(val) + continue + } + + // 3. behavior + if (isBehavior(val)) { + result.transitionBehavior = val + continue + } + + // 4. transition-property (剩下的就是属性名) + if (!result.transitionProperty) { + result.transitionProperty = dash2hump(val) + } + } + + // 处理时间值:第一个是 duration,第二个是 delay + if (timeValues.length >= 1) { + result.transitionDuration = timeValues[0] + } + if (timeValues.length >= 2) { + result.transitionDelay = timeValues[1] + } + + // 校验必需属性 + if (!result.transitionProperty) { + throw new Error('transition 缺少必需属性 transitionProperty') + } + if (!result.transitionDuration) { + throw new Error(`过渡 "${result.transitionProperty}" 缺少必需属性 transitionDuration`) + } + + return result +} + +/** + * 解析值字符串,支持括号内的内容作为一个整体 + * @param {string} str - 要解析的字符串 + * @param {string} char - 分隔符,默认为空格 + * @returns {string[]} 解析后的值数组 + */ +function parseValues(str, char = ' ') { + const result = [] + let temp = '' + let depth = 0 + + for (const c of str) { + if (c === '(') depth++ + else if (c === ')') depth-- + + if (c === char && depth === 0) { + if (temp) result.push(temp.trim()) + temp = '' + } else { + temp += c + } + } + + if (temp) result.push(temp.trim()) + return result +} + +const TIMING_FUNCTIONS = ['ease', 'linear', 'ease-in', 'ease-out', 'ease-in-out', 'step-start', 'step-end'] +const DIRECTIONS = ['normal', 'reverse', 'alternate', 'alternate-reverse'] +const FILL_MODES = ['none', 'forwards', 'backwards', 'both'] +const PLAY_STATES = ['running', 'paused'] +const BEHAVIOR = ['allow-discrete', 'normal'] +// 匹配包含参数形式的 timing-function,例如 cubic-bezier() / steps() / linear() +const TIMING_FUNCTIONS_EXP = /(cubic-bezier\s*\()|(steps\s*\()|(linear\s*\()/ + +function formatTimingFunction(val) { + if (!val || !isString(val)) return val + + const raw = val.trim() + const lower = raw.toLowerCase() + + // cubic-bezier(x1, y1, x2, y2) + if (lower.startsWith('cubic-bezier(')) { + const inner = raw.slice(raw.indexOf('(') + 1, raw.lastIndexOf(')')) + const parts = inner.split(',').map(item => item.trim()) + if (parts.length !== 4) return raw + const nums = parts.map(p => Number(p)) + if (nums.some(n => Number.isNaN(n))) return raw + return cubicBezier(nums[0], nums[1], nums[2], nums[3]) + } + + // steps(stepsNumber, modifier?) + if (lower.startsWith('steps(')) { + const inner = raw.slice(raw.indexOf('(') + 1, raw.lastIndexOf(')')) + if (!inner) return raw + const parts = inner.split(',').map(item => item.trim()).filter(Boolean) + const count = Number.parseInt(parts[0], 10) + if (!Number.isFinite(count) || count <= 0) return raw + const modifier = parts[1] + ? parts[1].replace(/^['"]|['"]$/g, '').trim() + : undefined + return modifier ? steps(count, modifier) : steps(count) + } + + // linear(...points) + if (lower.startsWith('linear(')) { + const inner = raw.slice(raw.indexOf('(') + 1, raw.lastIndexOf(')')) + if (!inner) return raw + const parts = inner.split(',').map(item => item.trim()).filter(Boolean) + if (!parts.length) return raw + + const args = parts.map(part => { + // 处理形如 "0.25 75%" 的写法 + const tokens = part.split(/\s+/).filter(Boolean) + if (tokens.length === 2) { + const num = Number(tokens[0]) + const percent = tokens[1] + if (!Number.isNaN(num)) { + return [num, percent] + } + } + const num = Number(part) + if (!Number.isNaN(num)) return num + return part + }) + + return linear(...args) + } + + return raw +} + +function isTimingFunction(val) { + const lower = val.toLowerCase() + // 支持函数形式: cubic-bezier(), steps(), linear() + if (TIMING_FUNCTIONS.includes(lower) || TIMING_FUNCTIONS_EXP.test(lower)) return true + return false +} + +function isTime(val) { + // 支持负数时间值 + return /^-?[\d.]+(s|ms)$/i.test(val) +} + +function isIterationCount(val) { + return val === 'infinite' || /^[\d.]+$/.test(val) +} + +function isDirection(val) { + return DIRECTIONS.includes(val.toLowerCase()) +} + +function isFillMode(val) { + return FILL_MODES.includes(val.toLowerCase()) +} + +function isPlayState(val) { + return PLAY_STATES.includes(val.toLowerCase()) +} +// 判断是否是 transition-behavior 值 +function isBehavior(val) { + // val === 'allow-discrete' || val === 'normal' + return BEHAVIOR.includes(val.toLowerCase()) +} diff --git a/packages/core/src/platform/builtInMixins/styleHelperMixin.ios.js b/packages/core/src/platform/builtInMixins/styleHelperMixin.ios.js index 8113fb97cb..6e6f083ffb 100644 --- a/packages/core/src/platform/builtInMixins/styleHelperMixin.ios.js +++ b/packages/core/src/platform/builtInMixins/styleHelperMixin.ios.js @@ -2,6 +2,7 @@ import { isObject, isArray, dash2hump, cached, isEmptyObject, hasOwn, getFocused import { StyleSheet, Dimensions } from 'react-native' import { reactive } from '../../observer/reactive' import Mpx from '../../index' +import { parseStyleAnimation, parseStyleTransition } from './parseAnimation' global.__mpxAppDimensionsInfo = { window: Dimensions.get('window'), @@ -319,6 +320,26 @@ export default function styleHelperMixin () { }) } const isEmpty = isNativeStaticStyle ? !result.length : isEmptyObject(result) + // parse animation + if (hasOwn(result, 'animationName') || hasOwn(result, 'animation')) { + const animationData = parseStyleAnimation(result) + const name = animationData.animationName + if (Array.isArray(name)) { + animationData.animationName = name.map(item => (this.__getClassStyle?.(item) || global.__getAppClassStyle?.(item))).filter(Boolean) + } else if (name) { + animationData.animationName = this.__getClassStyle?.(name) || global.__getAppClassStyle?.(name) || {} + } + mergeResult(animationData) + delete result.animation + } + // parse transition + if (hasOwn(result, 'transitionProperty') || hasOwn(result, 'transition')) { + const transitionData = parseStyleTransition(result, 'transition') + // console.error('parse transition result: ', transitionData) + mergeResult(transitionData) + delete result.transition + } + console.error('styleHelperMixin: result=', result) return isEmpty ? empty : result } } diff --git a/packages/webpack-plugin/lib/platform/style/wx/index.js b/packages/webpack-plugin/lib/platform/style/wx/index.js index 4dbbc9224e..3b649328d8 100644 --- a/packages/webpack-plugin/lib/platform/style/wx/index.js +++ b/packages/webpack-plugin/lib/platform/style/wx/index.js @@ -3,7 +3,7 @@ const { parseValues } = require('../../../utils/string') module.exports = function getSpec({ warn, error }) { // React Native 双端都不支持的 CSS property - const unsupportedPropExp = /^(white-space|text-overflow|animation|font-variant-caps|font-variant-numeric|font-variant-east-asian|font-variant-alternates|font-variant-ligatures|background-position|caret-color)$/ + const unsupportedPropExp = /^(white-space|text-overflow|font-variant-caps|font-variant-numeric|font-variant-east-asian|font-variant-alternates|font-variant-ligatures|background-position|caret-color)$/ const unsupportedPropMode = { // React Native ios 不支持的 CSS property ios: /^(vertical-align)$/, @@ -444,7 +444,7 @@ module.exports = function getSpec({ warn, error }) { // 单个值处理 // rotate 处理成 rotateZ key = key === 'rotate' ? 'rotateZ' : key - transform.push({ [key]: val }) + transform.push({ [key]: ['rotateX', 'rotateY', 'rotateZ', 'skewX', 'skewY'].includes(key) && !isNaN(+val) ? `${val}deg` : val }) break case 'matrix': transform.push({ [key]: parseValues(val, ',').map(val => +val) }) @@ -467,7 +467,7 @@ module.exports = function getSpec({ warn, error }) { if (key !== 'rotate' && index > 1) { unsupportedPropError({ prop: `${key}Z`, value, selector }, { mode }) } - return { [`${key}${xyz[index] || ''}`]: v.trim() } + return { [`${key}${xyz[index] || ''}`]: key === 'skew' && !isNaN(+v) ? `${v}deg` : v.trim() } })) break } diff --git a/packages/webpack-plugin/lib/react/style-helper.js b/packages/webpack-plugin/lib/react/style-helper.js index 07b779da81..3c55698d39 100644 --- a/packages/webpack-plugin/lib/react/style-helper.js +++ b/packages/webpack-plugin/lib/react/style-helper.js @@ -5,12 +5,15 @@ const getRulesRunner = require('../platform/index') const dash2hump = require('../utils/hump-dash').dash2hump const parseValues = require('../utils/string').parseValues const unitRegExp = /^\s*(-?\d+(?:\.\d+)?)(rpx|vw|vh|px)?\s*$/ +// const percentExp = /^((-?(\d+(\.\d+)?|\.\d+))%)$/ const hairlineRegExp = /^\s*hairlineWidth\s*$/ const varRegExp = /^--/ const cssPrefixExp = /^-(webkit|moz|ms|o)-/ function getClassMap ({ content, filename, mode, srcMode, ctorType, formatValueName, warn, error }) { const classMap = ctorType === 'page' - ? { [MPX_TAG_PAGE_SELECTOR]: { flex: 1, height: "'100%'" } } + ? { + [MPX_TAG_PAGE_SELECTOR]: { flex: 1, height: "'100%'" } + } : {} const root = postcss.parse(content, { @@ -39,28 +42,28 @@ function getClassMap ({ content, filename, mode, srcMode, ctorType, formatValueN function getMediaOptions (params) { return parseValues(params).reduce((option, item) => { if (['all', 'print'].includes(item)) { - if (item === 'media') { - option.type = item - } else { - error('not supported ', item) - return option - } + error(`Media type only support [screen], received ${item}, please check again!`) + return option } if (['not', 'only', 'or', ','].includes(item)) { - if (item === 'and') { - option.logical_operators = item - } else { - error('not supported ', item) - return option - } + error(`Media logical operator only support [and], received ${item}, please check again!`) + return option } const bracketsExp = /\((.+?)\)/ if (bracketsExp.test(item)) { - const range = parseValues((item.match(bracketsExp)?.[1] || ''), ':') + const mediaFeatureStr = item.match(bracketsExp)?.[1] || '' + // console.log(mediaFeatureStr, 999111) + const range = parseValues(mediaFeatureStr, ':') if (range.length < 2) { return option } else { - option[dash2hump(range[0])] = +formatValue(range[1]) + const mediaFeature = dash2hump(range[0]) + if (mediaFeature === 'maxWidth' || mediaFeature === 'minWidth') { + option[mediaFeature] = +formatValue(range[1]) + } else { + error(`Media feature only support [width], received [${mediaFeatureStr}], please check again!`) + return option + } } } return option @@ -76,16 +79,7 @@ function getClassMap ({ content, filename, mode, srcMode, ctorType, formatValueN error }) - // 目前所有 AtRule 只支持 @media,其他全部给出错误提示 - root.walkAtRules(rule => { - if (rule.name !== 'media') { - warn(`Only @media rule is supported in react native mode temporarily, but got @${rule.name}`) - // 删除不支持的 AtRule,防止其影响后续解析 - rule.remove() - } - }) - - root.walkRules(rule => { + function walkRule ({ rule, classMap, ruleName = '', options }) { const classMapValue = {} rule.walkDecls(({ prop, value }) => { if (value === 'undefined' || cssPrefixExp.test(prop) || cssPrefixExp.test(value)) return @@ -120,12 +114,24 @@ function getClassMap ({ content, filename, mode, srcMode, ctorType, formatValueN }) const classMapKeys = [] - const options = getMediaOptions(rule.parent.params || '') - const isMedia = options.maxWidth || options.minWidth selectorParser(selectors => { selectors.each(selector => { - if (selector.nodes.length === 1 && selector.nodes[0].type === 'class') { + if (selector.nodes.length === 1 && (selector.nodes[0].type === 'class')) { classMapKeys.push(selector.nodes[0].value) + } else if (ruleName === 'keyframes' && selector.nodes[0].type === 'tag') { + // 动画帧参数 + const value = selector.nodes[0].value + // const val = value.match(percentExp)?.[2] / 100 + if (value === 'from') { + // from + classMapKeys.push('0%') + } else if (value === 'to') { + // to + classMapKeys.push('100%') + } else { + // 百分比 + classMapKeys.push(value) + } } else { error('Only single class selector is supported in react native mode temporarily.') } @@ -137,7 +143,7 @@ function getClassMap ({ content, filename, mode, srcMode, ctorType, formatValueN if (Object.keys(classMapValue).length) { let _default = classMap[key]?._default let _media = classMap[key]?._media - if (isMedia) { + if (ruleName === 'media' && options && (options.minWidth || options.maxWidth)) { // 当前是媒体查询 _default = _default || {} _media = _media || [] @@ -146,8 +152,8 @@ function getClassMap ({ content, filename, mode, srcMode, ctorType, formatValueN value: classMapValue }) classMap[key] = { - _media, - _default + _default, + _media } } else if (_default) { // 已有媒体查询数据,此次非媒体查询 @@ -160,6 +166,45 @@ function getClassMap ({ content, filename, mode, srcMode, ctorType, formatValueN } }) } + } + // 目前所有 AtRule 只支持 @media & @keyframes,其他全部给出错误提示 + root.walkAtRules(rule => { + if (rule.name !== 'media' && rule.name !== 'keyframes') { + warn(`Only @media and @keyframes rules is supported in react native mode temporarily, but got @${rule.name}`) + // 删除不支持的 AtRule,防止其影响后续解析 + rule.remove() + return + } + const ruleName = rule.name + let ruleClassMap + let options + if (ruleName === 'media') { + options = getMediaOptions(rule.params) + ruleClassMap = classMap + } else if (ruleName === 'keyframes') { + ruleClassMap = {} + } + rule.walkRules(node => { + walkRule({ + rule: node, + ruleName, + options, + classMap: ruleClassMap + }) + }) + if (ruleName === 'keyframes') { + const animationName = rule.params + if (Object.keys(ruleClassMap).length > 0 && animationName) { + classMap[animationName] = ruleClassMap + } + } + }) + root.walkRules(rule => { + if (rule.parent.type === 'atrule') return + walkRule({ + rule, + classMap + }) }) return classMap } diff --git a/packages/webpack-plugin/lib/runtime/components/react/animationHooks/index.ts b/packages/webpack-plugin/lib/runtime/components/react/animationHooks/index.ts index 82faf63f35..36514addf6 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/animationHooks/index.ts +++ b/packages/webpack-plugin/lib/runtime/components/react/animationHooks/index.ts @@ -1,7 +1,6 @@ import { error, collectDataset, hasOwn } from '@mpxjs/utils' import { useRef } from 'react' import useAnimationAPIHooks from './useAnimationAPIHooks' -import useTransitionHooks from './useTransitionHooks' import type { AnimatableValue } from 'react-native-reanimated' import type { MutableRefObject } from 'react' import type { NativeSyntheticEvent } from 'react-native' @@ -14,7 +13,7 @@ export default function useAnimationHooks (props: _ViewProps & { enableAni const { style: originalStyle = {}, enableAnimation, animation, transitionend, layoutRef } = props // 记录动画类型 let animationType = '' - if (hasOwn(originalStyle, 'animation') || (hasOwn(originalStyle, 'animationName') && hasOwn(originalStyle, 'animationDuration'))) { + if (hasOwn(originalStyle, 'animationName') && hasOwn(originalStyle, 'animationDuration')) { // css animation 只做检测提示 animationType = 'animation' } @@ -22,7 +21,7 @@ export default function useAnimationHooks (props: _ViewProps & { enableAni animationType = 'api' } // 优先级 css transition > API - if (hasOwn(originalStyle, 'transition') || (hasOwn(originalStyle, 'transitionProperty') && hasOwn(originalStyle, 'transitionDuration'))) { + if (hasOwn(originalStyle, 'transitionProperty') && hasOwn(originalStyle, 'transitionDuration')) { animationType = 'transition' } // 优先以 enableAnimation 定义类型为准 @@ -34,11 +33,11 @@ export default function useAnimationHooks (props: _ViewProps & { enableAni // 允许 API、CssTransition 到 none,不允许 API、CssTransition 互切,不允许 none 到 API、CssTransition error('[Mpx runtime error]: The animation type should be stable in the component lifecycle, or you can set animation type with [enable-animation].') } - if (animationType === 'animation') { - // 暂不支持 CssAnimation 提示 - error('[Mpx runtime error]: CSS animation is not supported yet') - return { enableStyleAnimation: false } - } + // if (animationType === 'animation') { + // // 暂不支持 CssAnimation 提示 + // error('[Mpx runtime error]: CSS animation is not supported yet') + // return { enableStyleAnimation: false } + // } if (!animationTypeRef.current) return { enableStyleAnimation: false } const hooksProps = { style: originalStyle } @@ -69,7 +68,6 @@ export default function useAnimationHooks (props: _ViewProps & { enableAni animationStyle: animationTypeRef.current === 'api' // eslint-disable-next-line react-hooks/rules-of-hooks ? useAnimationAPIHooks(hooksProps) - // eslint-disable-next-line react-hooks/rules-of-hooks - : useTransitionHooks(hooksProps) + : undefined } } diff --git a/packages/webpack-plugin/lib/runtime/components/react/animationHooks/utils.ts b/packages/webpack-plugin/lib/runtime/components/react/animationHooks/utils.ts index 8845dc8f91..921fef1b01 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/animationHooks/utils.ts +++ b/packages/webpack-plugin/lib/runtime/components/react/animationHooks/utils.ts @@ -47,7 +47,7 @@ export type InterpolateOutput = { export type AnimationHooksPropsType = _ViewProps & { transitionend?: CustomAnimationCallback } // ms s 单位匹配 -export const secondRegExp = /^\s*(\d*(?:\.\d+)?)(s|ms)?\s*$/ +export const secondRegExp = /^\s*(\d*(?:\.\d+)?)(s|ms)\s*$/ export const cubicBezierExp = /cubic-bezier\(["']?(.*?)["']?\)/ export const percentExp = /^((-?(\d+(\.\d+)?|\.\d+))%)$/ // export const PropNameColorExp = /^c|Color$/ diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-view.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-view.tsx index 0d26e66154..6dc3f82c35 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-view.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-view.tsx @@ -784,6 +784,8 @@ const _View = forwardRef, _ViewProps>((viewProps, r transitionend }) + const hasAnimatedView = enableStyleAnimation || viewStyle.transtionName || viewStyle.animationName + const innerProps = useInnerProps( extendObject( {}, @@ -791,7 +793,7 @@ const _View = forwardRef, _ViewProps>((viewProps, r layoutProps, { ref: nodeRef, - style: enableStyleAnimation ? [viewStyle, animationStyle] : viewStyle + style: animationStyle ? [viewStyle, animationStyle] : viewStyle } ), [ @@ -816,7 +818,7 @@ const _View = forwardRef, _ViewProps>((viewProps, r enableFastImage }) - let finalComponent: JSX.Element = enableStyleAnimation + let finalComponent: JSX.Element = hasAnimatedView ? createElement(Animated.View, innerProps, childNode) : createElement(View, innerProps, childNode) diff --git a/packages/webpack-plugin/lib/runtime/components/react/utils.tsx b/packages/webpack-plugin/lib/runtime/components/react/utils.tsx index b50618dba2..993a9169b1 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/utils.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/utils.tsx @@ -357,7 +357,9 @@ function parseTransform (transformStr: string) { // rotate 处理成 rotateZ key = key === 'rotate' ? 'rotateZ' : key // 单个值处理 - transform.push({ [key]: global.__formatValue(val) }) + transform.push({ + [key]: ['rotateX', 'rotateY', 'rotateZ', 'skewX', 'skewY'].includes(key) && !isNaN(+val) ? `${val}deg` : global.__formatValue(val) + }) break case 'matrix': transform.push({ [key]: parseValues(val, ',').map(val => +val) }) @@ -377,7 +379,7 @@ function parseTransform (transformStr: string) { } const xyz = ['X', 'Y', 'Z'] transform.push(...vals.map((v, index) => { - return { [`${key}${xyz[index] || ''}`]: global.__formatValue(v.trim()) } + return { [`${key}${xyz[index] || ''}`]: key === 'skew' && !isNaN(+v) ? `${v}deg` : global.__formatValue(v.trim()) } })) break } diff --git a/packages/webpack-plugin/package.json b/packages/webpack-plugin/package.json index ee5d3e88a2..3131a3c063 100644 --- a/packages/webpack-plugin/package.json +++ b/packages/webpack-plugin/package.json @@ -90,14 +90,15 @@ "@types/glob": "^8.1.0", "@types/react": "^18.2.79", "glob": "^11.0.2", - "react-native": "^0.74.5", + "react-native": "^0.78.0", "react-native-gesture-handler": "^2.18.1", "react-native-linear-gradient": "^2.8.3", - "react-native-reanimated": "^3.15.2", + "react-native-reanimated": "^4.2.2", "react-native-safe-area-context": "^4.12.0", "react-native-svg": "^15.8.0", "react-native-video": "^6.9.0", "react-native-webview": "^13.12.2", + "react-native-worklets": "^0.7.0", "rimraf": "^6.0.1", "webpack": "^5.75.0" },