diff --git a/docs-vuepress/api/compile.md b/docs-vuepress/api/compile.md index 260b736b58..f9eca8aee3 100644 --- a/docs-vuepress/api/compile.md +++ b/docs-vuepress/api/compile.md @@ -1097,6 +1097,25 @@ module.exports = defineConfig({ }) ``` +### disableRequireAsync + +`boolean = false` + +Mpx 框架在输出 微信小程序、支付宝小程序、字节小程序、Web 平台时,默认支持分包异步化能力,但若在某些场景下需要关闭该能力,可配置该项。 + +```js +// vue.config.js +module.exports = defineConfig({ + pluginOptions: { + mpx: { + plugin: { + disableRequireAsync: true + } + } + } +}) +``` + ### optimizeSize `boolean = false` diff --git a/docs-vuepress/guide/advance/async-subpackage.md b/docs-vuepress/guide/advance/async-subpackage.md index 4a401d6664..72b24681d5 100644 --- a/docs-vuepress/guide/advance/async-subpackage.md +++ b/docs-vuepress/guide/advance/async-subpackage.md @@ -5,6 +5,14 @@ 具体功能介绍和功能目的可 [点击查看](https://developers.weixin.qq.com/miniprogram/dev/framework/subpackages/async.html), Mpx对于分包异步化功能进行了完整支持。 +当前 Mpx 框架默认支持以下平台的分包异步化能力: +* 微信小程序 +* 支付宝小程序 +* 字节小程序 +* Web + +在非上述平台,异步分包代码会默认降级。 + ## 跨分包自定义组件引用 >一个分包使用其他分包的自定义组件时,由于其他分包还未下载或注入,其他分包的组件处于不可用的状态。通过为其他分包的自定义组件设置 占位组件, 我们可以先渲染占位组件作为替代,在分包下载完成后再进行替换。 @@ -88,5 +96,4 @@ require.async('../commonPackage/index.js?root=subPackageB').then(pkg => { }) ``` -- 注意项:目前该能力仅微信平台下支持,其他平台下框架将会自动降级 diff --git a/packages/api-proxy/src/platform/api/animation/animation.react.js b/packages/api-proxy/src/platform/api/animation/animation.react.js new file mode 100644 index 0000000000..b3c59c11bb --- /dev/null +++ b/packages/api-proxy/src/platform/api/animation/animation.react.js @@ -0,0 +1,281 @@ +class Animation { + constructor ( + { + duration = 400, + delay = 0, + timingFunction = 'linear', + transformOrigin = '50% 50% 0' + } = {} + ) { + // 默认值 + this._setDefault(duration, delay, timingFunction, transformOrigin) + this.id = 0 + } + + _transformUnit (...args) { + return args.map(each => { + return global.__formatValue(each) + }) + } + + _formatTransformOrigin (transformOrigin) { + const transformOriginArr = transformOrigin.trim().split(/\s+/, 3).map(item => global.__formatValue(item)) + switch (transformOriginArr.length) { + case 0: + transformOriginArr.push('50%', '50%', 0) + break + case 1: + transformOriginArr.push('50%', 0) + break + case 2: + transformOriginArr.push(0) + break + } + return transformOriginArr + } + + // 设置默认值 + _setDefault (duration, delay, timingFunction, transformOrigin) { + this.DEFAULT = { duration, delay, timingFunction, transformOrigin } + } + + // 属性组合 + rules = new Map() + // transform 对象 + transform = new Map() + // 组合动画 + steps = [] + + matrix (a, b, c, d, tx, ty) { // Todo + console.error('React Native 不支持 matrix 动画') + // this.transform.set('matrix', [a, b, c, d, tx, ty]) + return this + } + + matrix3d (a1, b1, c1, d1, + a2, b2, c2, d2, + a3, b3, c3, d3, + a4, b4, c4, d4 + ) { + console.error('React Native 不支持 matrix3d 动画') + // this.transform.set('matrix3d', [ // Todo + // a1, b1, c1, d1, + // a2, b2, c2, d2, + // a3, b3, c3, d3, + // a4, b4, c4, d4 + // ]) + return this + } + + rotate (angle) { // 旋转变换 + this.transform.set('rotate', `${angle}deg`) + return this + } + + rotate3d (x, y, z, angle) { + if (typeof y !== 'number') { + this.transform.set('rotate3d', x) + } else { + // this.transform.set('rotate3d', [x, y, z, angle]) + this.rotateX(x) + this.rotateY(y) + this.rotateZ(z) + this.rotate(angle) + } + return this + } + + rotateX (angle) { + this.transform.set('rotateX', `${angle}deg`) + return this + } + + rotateY (angle) { + this.transform.set('rotateY', `${angle}deg`) + return this + } + + rotateZ (angle) { + this.transform.set('rotateZ', `${angle}deg`) + return this + } + + scale (x, y) { + const scaleY = (typeof y !== 'undefined' && y !== null) ? y : x + this.scaleX(x) + this.scaleY(scaleY) + // this.transform.set('scale', [x, scaleY]) + return this + } + + scaleX (scale) { + this.transform.set('scaleX', scale) + return this + } + + scaleY (scale) { + this.transform.set('scaleY', scale) + return this + } + + scaleZ (scale) { // Todo Invariant Violation: Invalid transform scaleZ: {"scaleZ":0} + console.error('React Native 不支持 transform scaleZ') + // this.transform.set('scaleZ', scale) + return this + } + + scale3d (x, y, z) { // Todo Invariant Violation: Invalid transform scaleZ: {"scaleZ":0} + console.error('React Native 不支持 transform scaleZ,故不支持 scale3d') + // this.scaleX(x) + // this.scaleY(y) + // this.scaleZ(z) + return this + } + + skew (x, y) { + // this.transform.set('skew', [x, y]) + this.skewX(x) + this.skewY(y) + return this + } + + skewX (angle) { + this.transform.set('skewX', `${angle}deg`) + return this + } + + skewY (angle) { + this.transform.set('skewY', `${angle}deg`) + return this + } + + translate (x, y) { + [x, y] = this._transformUnit(x, y) + // this.transform.set('translate', [x, y]) + this.translateX(x) + this.translateY(y) + return this + } + + translateX (translate) { + [translate] = this._transformUnit(translate) + this.transform.set('translateX', translate) + return this + } + + translateY (translate) { + [translate] = this._transformUnit(translate) + this.transform.set('translateY', translate) + return this + } + + translateZ (translate) { // Todo Invariant Violation: Invalid transform translateZ: {"translateZ":0} + console.error('React Native 不支持 transform translateZ') + // [translate] = this._transformUnit(translate) + // this.transform.set('translateZ', translate) + return this + } + + translate3d (x, y, z) { // Todo Invariant Violation: Invalid transform translateZ: {"translateZ":0} + console.error('React Native 不支持 transform translateZ,故无法支持 translate3d') + // [x, y, z] = this._transformUnit(x, y, z) + // // this.transform.set('translate3d', [x, y, z]) + // this.translateX(x) + // this.translateY(y) + // this.translateZ(z) + return this + } + + opacity (value) { + this.rules.set('opacity', value) + return this + } + + backgroundColor (value) { + this.rules.set('backgroundColor', value) + return this + } + + width (value) { + [value] = this._transformUnit(value) + this.rules.set('width', value) + return this + } + + height (value) { + [value] = this._transformUnit(value) + this.rules.set('height', value) + return this + } + + top (value) { + [value] = this._transformUnit(value) + this.rules.set('top', value) + return this + } + + right (value) { + [value] = this._transformUnit(value) + this.rules.set('right', value) + return this + } + + bottom (value) { + [value] = this._transformUnit(value) + this.rules.set('bottom', value) + return this + } + + left (value) { + [value] = this._transformUnit(value) + this.rules.set('left', value) + return this + } + + // 关键帧载入 + step (arg = {}) { + const { DEFAULT } = this + let { + duration = DEFAULT.duration, + delay = DEFAULT.delay, + timingFunction = DEFAULT.timingFunction, + transformOrigin = DEFAULT.transformOrigin + } = arg + if (typeof transformOrigin !== 'string') { + console.error('Value of transformOrigin only support string type, please check again') + transformOrigin = DEFAULT.transformOrigin + } + this.steps.push({ + animatedOption: { + duration, + delay, + timingFunction, + transformOrigin: this._formatTransformOrigin(transformOrigin) + }, + rules: this.rules, + transform: this.transform + }) + // 清空 rules 和 transform + this.rules = new Map() + this.transform = new Map() + return this + } + + // 数据 + createAnimationData () { + const steps = this.steps + this.steps = [] + return steps + } + + // 动画数据产出 + export () { + this.id++ + return { + id: this.id, + actions: this.createAnimationData() + } + } +} + +export default Animation diff --git a/packages/api-proxy/src/platform/api/animation/index.android.js b/packages/api-proxy/src/platform/api/animation/index.android.js new file mode 100644 index 0000000000..25aabdbcf9 --- /dev/null +++ b/packages/api-proxy/src/platform/api/animation/index.android.js @@ -0,0 +1 @@ +export * from './index.ios' diff --git a/packages/api-proxy/src/platform/api/animation/index.ios.js b/packages/api-proxy/src/platform/api/animation/index.ios.js new file mode 100644 index 0000000000..d372710f15 --- /dev/null +++ b/packages/api-proxy/src/platform/api/animation/index.ios.js @@ -0,0 +1,5 @@ +import Animation from './animation.react' + +export const createAnimation = (option) => { + return new Animation(option) +} diff --git a/packages/api-proxy/src/platform/api/create-intersection-observer/index.ali.js b/packages/api-proxy/src/platform/api/create-intersection-observer/index.ali.js index a05463da5b..c4ec5a78c7 100644 --- a/packages/api-proxy/src/platform/api/create-intersection-observer/index.ali.js +++ b/packages/api-proxy/src/platform/api/create-intersection-observer/index.ali.js @@ -1,6 +1,9 @@ import { ENV_OBJ } from '../../../common/js' function createIntersectionObserver (component, options = {}) { + if (options.observeAll) { + options.selectAll = options.observeAll + } return ENV_OBJ.createIntersectionObserver(options) } diff --git a/packages/api-proxy/src/platform/api/create-intersection-observer/index.ios.js b/packages/api-proxy/src/platform/api/create-intersection-observer/index.ios.js new file mode 100644 index 0000000000..deafb6038c --- /dev/null +++ b/packages/api-proxy/src/platform/api/create-intersection-observer/index.ios.js @@ -0,0 +1,9 @@ +import IntersectionObserver from './rnIntersectionObserver' + +function createIntersectionObserver (comp, opt, config) { + return new IntersectionObserver(comp, opt, config) +} + +export { + createIntersectionObserver +} diff --git a/packages/api-proxy/src/platform/api/create-intersection-observer/rnIntersectionObserver.js b/packages/api-proxy/src/platform/api/create-intersection-observer/rnIntersectionObserver.js new file mode 100644 index 0000000000..1f4a2d523b --- /dev/null +++ b/packages/api-proxy/src/platform/api/create-intersection-observer/rnIntersectionObserver.js @@ -0,0 +1,239 @@ +import { isArray, isObject, isString, noop } from '@mpxjs/utils' +import throttle from 'lodash/throttle' +import { Dimensions } from 'react-native' +import { getFocusedNavigation } from '../../../common/js' + +const WindowRefStr = 'window' +const IgnoreTarget = 'ignore' +const DefaultMargin = { top: 0, bottom: 0, left: 0, right: 0 } +let idCount = 0 + +class RNIntersectionObserver { + constructor (component, options, intersectionCtx) { + this.id = idCount++ + this.component = component + this.options = options + this.thresholds = options.thresholds.sort((a, b) => a - b) || [0] + this.initialRatio = options.initialRatio || 0 + this.observeAll = options.observeAll || false + + // 组件上挂载对应的observers,用于在组件销毁的时候进行批量disconnect + this.component._intersectionObservers = this.component.__intersectionObservers || [] + this.component._intersectionObservers.push(this) + + this.observerRefs = null + this.relativeRef = null + this.margins = DefaultMargin + this.callback = noop + + this.throttleMeasure = this.getThrottleMeasure(options.throttleTime || 100) + + // 记录上一次相交的比例 + this.previousIntersectionRatio = [] + + // 添加实例添加到上下文中,滚动组件可以获取到上下文内的实例从而触发滚动 + if (intersectionCtx && isObject(intersectionCtx)) { + this.intersectionCtx = intersectionCtx + this.intersectionCtx[this.id] = this + } + return this + } + + // 支持传递ref 或者 selector + relativeTo (selector, margins = {}) { + let relativeRef + if (isString(selector)) { + relativeRef = this.component.__selectRef(selector, 'node') + } + if (isObject(selector)) { + relativeRef = selector.nodeRefs?.[0] + } + if (relativeRef) { + this.relativeRef = relativeRef + this.margins = Object.assign({}, DefaultMargin, margins) + } else { + console.warn(`node ${selector}is not found. The relative node for intersection observer will be ignored`) + } + return this + } + + relativeToViewport (margins = {}) { + this.relativeRef = WindowRefStr + this.margins = Object.assign({}, DefaultMargin, margins) + return this + } + + observe (selector, callback) { + if (this.observerRefs) { + console.error('"observe" call can be only called once in IntersectionObserver') + return + } + let targetRef = null + if (this.observeAll) { + targetRef = this.component.__selectRef(selector, 'node', true) + } else { + targetRef = this.component.__selectRef(selector, 'node') + } + if (!targetRef || targetRef.length === 0) { + console.error('intersection observer target not found') + return + } + this.observerRefs = isArray(targetRef) ? targetRef : [targetRef] + this.callback = callback + this._measureTarget(true) + } + + _getWindowRect () { + if (this.windowRect) return this.windowRect + const navigation = getFocusedNavigation() + const screen = Dimensions.get('screen') + const navigationLayout = navigation.layout || { + x: 0, + y: 0, + width: screen.width, + height: screen.height + } + + const windowRect = { + top: navigationLayout.y + this.margins.top, + left: navigationLayout.x + this.margins.left, + right: navigationLayout.width - this.margins.right, + bottom: navigationLayout.y + navigationLayout.height - this.margins.bottom + } + + this.windowRect = windowRect + return this.windowRect + } + + _getReferenceRect (targetRef) { + const targetRefs = isArray(targetRef) ? targetRef : [targetRef] + const targetPromiseQueue = [] + targetRefs.forEach((targetRefItem) => { + if (targetRefItem === WindowRefStr) { + targetPromiseQueue.push(this._getWindowRect()) + return + } + // 当节点前面存在后面移除的时候可能会存在拿不到target的情况,此处直接忽略留一个占位不用做计算即可 + // 测试节点移除之后 targetRefItem.getNodeInstance().nodeRef都存在,只是current不存在了 + if (!targetRefItem || !targetRefItem.getNodeInstance().nodeRef.current) { + targetPromiseQueue.push(Promise.resolve(IgnoreTarget)) + return + } + const target = targetRefItem.getNodeInstance().nodeRef.current + targetPromiseQueue.push(new Promise((resolve) => { + target.measureInWindow( + (x, y, width, height) => { + const boundingClientRect = { + left: x, + top: y, + right: x + width, + bottom: y + height, + width: width, + height: height + } + resolve(boundingClientRect) + } + ) + })) + }) + + if (isArray(targetRef)) { + return Promise.all(targetPromiseQueue) + } else { + return targetPromiseQueue[0] + } + } + + _restrictValueInRange (start = 0, end = 0, value = 0) { + return Math.min(Math.max(start, value), end) + } + + _isInsectedFn (intersectionRatio, previousIntersectionRatio, thresholds) { + // console.log('nowintersectionRatio, previousIntersectionRatio', [intersectionRatio, previousIntersectionRatio]) + let nowIndex = -1 + let previousIndex = -1 + thresholds.forEach((item, index) => { + if (intersectionRatio >= item) { + nowIndex = index + } + if (previousIntersectionRatio >= item) { + previousIndex = index + } + }) + return !(nowIndex === previousIndex) + } + + // 计算相交区域 + _measureIntersection ({ observeRect, relativeRect, observeIndex, isInit }) { + const visibleRect = { + left: this._restrictValueInRange(relativeRect.left, relativeRect.right, observeRect.left), + top: this._restrictValueInRange(relativeRect.top, relativeRect.bottom, observeRect.top), + right: this._restrictValueInRange(relativeRect.left, relativeRect.right, observeRect.right), + bottom: this._restrictValueInRange(relativeRect.top, relativeRect.bottom, observeRect.bottom) + } + + const targetArea = (observeRect.bottom - observeRect.top) * (observeRect.right - observeRect.left) + const visibleArea = (visibleRect.bottom - visibleRect.top) * (visibleRect.right - visibleRect.left) + const intersectionRatio = targetArea ? visibleArea / targetArea : 0 + + const isInsected = isInit ? intersectionRatio > this.initialRatio : this._isInsectedFn(intersectionRatio, this.previousIntersectionRatio[observeIndex], this.thresholds) + this.previousIntersectionRatio[observeIndex] = intersectionRatio + + return { + intersectionRatio, + intersectionRect: { + top: visibleRect.top, + bottom: relativeRect.bottom, + left: visibleRect.left, + right: relativeRect.right + }, + isInsected + } + } + + getThrottleMeasure (throttleTime) { + return throttle(() => { + this._measureTarget() + }, throttleTime) + } + + // 计算节点的rect信息 + _measureTarget (isInit = false) { + Promise.all([ + this._getReferenceRect(this.observerRefs), + this._getReferenceRect(this.relativeRef) + ]).then(([observeRects, relativeRect]) => { + if (relativeRect === IgnoreTarget) return + observeRects.forEach((observeRect, index) => { + if (observeRect === IgnoreTarget) return + const { intersectionRatio, intersectionRect, isInsected } = this._measureIntersection({ + observeRect, + observeIndex: index, + relativeRect, + isInit + }) + // 初次调用的 + if (isInsected) { + this.callback({ + // index: index, + id: this.observerRefs[index].getNodeInstance().props?.current?.id, + dataset: this.observerRefs[index].getNodeInstance().props?.current?.dataset || {}, + intersectionRatio: Math.round(intersectionRatio * 100) / 100, + intersectionRect, + boundingClientRect: observeRect, + relativeRect: relativeRect, + time: Date.now() + }) + } + }) + }).catch((e) => { + console.log('_measureTarget fail', e) + }) + } + + disconnect () { + if (this.intersectionCtx) delete this.intersectionCtx[this.id] + } +} + +export default RNIntersectionObserver diff --git a/packages/core/src/core/proxy.js b/packages/core/src/core/proxy.js index bac153dc53..11d2b75128 100644 --- a/packages/core/src/core/proxy.js +++ b/packages/core/src/core/proxy.js @@ -224,6 +224,11 @@ export default class MpxProxy { if (this.update) this.update.active = false this.callHook(UNMOUNTED) this.state = UNMOUNTED + if (this._intersectionObservers) { + this._intersectionObservers.forEach((observer) => { + observer.disconnect() + }) + } } isUnmounted () { diff --git a/packages/core/src/platform/export/index.js b/packages/core/src/platform/export/index.js index ab143b1e2b..61af32a8e8 100644 --- a/packages/core/src/platform/export/index.js +++ b/packages/core/src/platform/export/index.js @@ -1,4 +1,3 @@ - export { watchEffect, watchSyncEffect, diff --git a/packages/core/src/platform/patch/react/getDefaultOptions.ios.js b/packages/core/src/platform/patch/react/getDefaultOptions.ios.js index 5e2d6f14b2..db6e35b333 100644 --- a/packages/core/src/platform/patch/react/getDefaultOptions.ios.js +++ b/packages/core/src/platform/patch/react/getDefaultOptions.ios.js @@ -3,12 +3,13 @@ import * as ReactNative from 'react-native' import { ReactiveEffect } from '../../../observer/effect' import { watch } from '../../../observer/watch' import { reactive, set, del } from '../../../observer/reactive' -import { hasOwn, isFunction, noop, isObject, error, getByPath, collectDataset, hump2dash } from '@mpxjs/utils' +import { hasOwn, isFunction, noop, isObject, getByPath, collectDataset, hump2dash } from '@mpxjs/utils' import MpxProxy from '../../../core/proxy' import { BEFOREUPDATE, ONLOAD, UPDATED, ONSHOW, ONHIDE, ONRESIZE, REACTHOOKSEXEC } from '../../../core/innerLifecycle' import mergeOptions from '../../../core/mergeOptions' import { queueJob } from '../../../observer/scheduler' -import { createSelectorQuery } from '@mpxjs/api-proxy' +import { createSelectorQuery, createIntersectionObserver } from '@mpxjs/api-proxy' +import { IntersectionObserverContext } from '@mpxjs/webpack-plugin/lib/runtime/components/react/dist/context' function getSystemInfo () { const window = ReactNative.Dimensions.get('window') @@ -68,7 +69,7 @@ function getRootProps (props) { return rootProps } -function createInstance ({ propsRef, type, rawOptions, currentInject, validProps, components, pageId }) { +function createInstance ({ propsRef, type, rawOptions, currentInject, validProps, components, pageId, intersectionCtx }) { const instance = Object.create({ setData (data, callback) { return this.__mpxProxy.forceUpdate(data, { sync: true }, callback) @@ -183,8 +184,8 @@ function createInstance ({ propsRef, type, rawOptions, currentInject, validProps createSelectorQuery () { return createSelectorQuery().in(this) }, - createIntersectionObserver () { - error('createIntersectionObserver is not supported in react native, please use ref instead') + createIntersectionObserver (opt) { + return createIntersectionObserver(this, opt, intersectionCtx) }, ...rawOptions.methods }, { @@ -349,12 +350,13 @@ export function getDefaultOptions ({ type, rawOptions = {}, currentInject }) { const defaultOptions = memo(forwardRef((props, ref) => { const instanceRef = useRef(null) const propsRef = useRef(null) + const intersectionCtx = useContext(IntersectionObserverContext) const pageId = useContext(RouteContext) propsRef.current = props let isFirst = false if (!instanceRef.current) { isFirst = true - instanceRef.current = createInstance({ propsRef, type, rawOptions, currentInject, validProps, components, pageId }) + instanceRef.current = createInstance({ propsRef, type, rawOptions, currentInject, validProps, components, pageId, intersectionCtx }) } const instance = instanceRef.current useImperativeHandle(ref, () => { @@ -422,6 +424,7 @@ export function getDefaultOptions ({ type, rawOptions = {}, currentInject }) { const pageConfig = Object.assign({}, global.__mpxPageConfig, currentInject.pageConfig) const Page = ({ navigation, route }) => { const currentPageId = useMemo(() => ++pageId, []) + const intersectionObservers = useRef({}) usePageStatus(navigation, currentPageId) useLayoutEffect(() => { @@ -480,12 +483,17 @@ export function getDefaultOptions ({ type, rawOptions = {}, currentInject }) { { value: currentPageId }, - createElement(defaultOptions, - { - navigation, - route, - id: currentPageId - } + createElement(IntersectionObserverContext.Provider, + { + value: intersectionObservers.current + }, + createElement(defaultOptions, + { + navigation, + route, + id: currentPageId + } + ) ) ) ) @@ -495,6 +503,5 @@ export function getDefaultOptions ({ type, rawOptions = {}, currentInject }) { } return Page } - return defaultOptions } diff --git a/packages/webpack-plugin/lib/index.js b/packages/webpack-plugin/lib/index.js index ebb0555637..218f8d5952 100644 --- a/packages/webpack-plugin/lib/index.js +++ b/packages/webpack-plugin/lib/index.js @@ -174,6 +174,7 @@ class MpxWebpackPlugin { options.subpackageModulesRules = options.subpackageModulesRules || {} options.forceMainPackageRules = options.forceMainPackageRules || {} options.forceProxyEventRules = options.forceProxyEventRules || {} + options.disableRequireAsync = options.disableRequireAsync || false options.miniNpmPackages = options.miniNpmPackages || [] options.fileConditionRules = options.fileConditionRules || { include: () => true @@ -710,7 +711,8 @@ class MpxWebpackPlugin { useRelativePath: this.options.useRelativePath, removedChunks: [], forceProxyEventRules: this.options.forceProxyEventRules, - supportRequireAsync: this.options.mode === 'wx' || this.options.mode === 'ali' || this.options.mode === 'tt' || isWeb(this.options.mode), + // 若配置disableRequireAsync=true, 则全平台构建不支持异步分包 + supportRequireAsync: !this.options.disableRequireAsync && (this.options.mode === 'wx' || this.options.mode === 'ali' || this.options.mode === 'tt' || isWeb(this.options.mode)), partialCompileRules: this.options.partialCompileRules, collectDynamicEntryInfo: ({ resource, packageName, filename, entryType, hasAsync }) => { const curInfo = mpx.dynamicEntryInfo[packageName] = mpx.dynamicEntryInfo[packageName] || { diff --git a/packages/webpack-plugin/lib/platform/style/wx/index.js b/packages/webpack-plugin/lib/platform/style/wx/index.js index b7e4e83390..bf8bf95284 100644 --- a/packages/webpack-plugin/lib/platform/style/wx/index.js +++ b/packages/webpack-plugin/lib/platform/style/wx/index.js @@ -190,12 +190,16 @@ module.exports = function getSpec ({ warn, error }) { flex: ['flexGrow', 'flexShrink', 'flexBasis'], // flex-flow: <'flex-direction'> or flex-flow: <'flex-direction'> and <'flex-wrap'> 'flex-flow': ['flexDirection', 'flexWrap'], - 'border-radius': ['borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomRightRadius', 'borderBottomLeftRadius'] + 'border-radius': ['borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomRightRadius', 'borderBottomLeftRadius'], + 'border-width': ['borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth'], + 'border-color': ['borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor'], + margin: ['marginTop', 'marginRight', 'marginBottom', 'marginLeft'], + padding: ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'] } const formatAbbreviation = ({ prop, value, selector }, { mode }) => { const original = `${prop}:${value}` const props = AbbreviationMap[prop] - const values = parseValues(value) + const values = Array.isArray(value) ? value : parseValues(value) const cssMap = [] let idx = 0 let propsIdx = 0 @@ -255,32 +259,20 @@ module.exports = function getSpec ({ warn, error }) { return cssMap } - // margin padding - const formatMargins = ({ prop, value, selector }) => { - const values = parseValues(value) - // format - let suffix = [] + const formatCompositeVal = ({ prop, value, selector }, { mode }) => { + const values = parseValues(value).splice(0, 4) switch (values.length) { - // case 1: + case 1: + verifyValues({ prop, value, selector }, false) + return { prop, value } case 2: - suffix = ['Vertical', 'Horizontal'] + values.push(...values) break case 3: - suffix = ['Top', 'Horizontal', 'Bottom'] - break - case 4: - suffix = ['Top', 'Right', 'Bottom', 'Left'] + values.push(values[1]) break } - return values.map((value, index) => { - const newProp = `${prop}${suffix[index] || ''}` - // validate - verifyValues({ prop: hump2dash(newProp), value, selector }, false) - return { - prop: newProp, - value: value - } - }) + return formatAbbreviation({ prop, value: values, selector }, { mode }) } // line-height @@ -374,22 +366,6 @@ module.exports = function getSpec ({ warn, error }) { return false } - // border-radius 缩写转换 - const getBorderRadius = ({ prop, value, selector }, { mode }) => { - const values = parseValues(value) - if (values.length === 1) { - verifyValues({ prop, value, selector }, false) - return { prop, value } - } else { - if (values.length === 2) { - values.push(...values) - } else if (values.length === 3) { - values.push(values[1]) - } - return formatAbbreviation({ prop, value: values.join(' ') }, { mode }) - } - } - // transform 转换 const formatTransform = ({ prop, value, selector }, { mode }) => { if (Array.isArray(value)) return { prop, value } @@ -566,15 +542,10 @@ module.exports = function getSpec ({ warn, error }) { ios: checkBackgroundImage, android: checkBackgroundImage }, - { - test: 'border-radius', - ios: getBorderRadius, - android: getBorderRadius - }, { // margin padding 内外边距的处理 - test: /^(margin|padding)$/, - ios: formatMargins, - android: formatMargins + test: /^(margin|padding|border-radius|border-width|border-color)$/, + ios: formatCompositeVal, + android: formatCompositeVal }, { // line-height 换算 test: 'line-height', diff --git a/packages/webpack-plugin/lib/runtime/components/react/context.ts b/packages/webpack-plugin/lib/runtime/components/react/context.ts index 2f3f8422b4..057b8325b0 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/context.ts +++ b/packages/webpack-plugin/lib/runtime/components/react/context.ts @@ -25,6 +25,12 @@ export interface FormContextValue { reset: () => void } +export interface IntersectionObserver { + [key: number]: { + throttleMeasure: () => void + } +} + export const MovableAreaContext = createContext({ width: 0, height: 0 }) export const FormContext = createContext(null) @@ -38,3 +44,5 @@ export const LabelContext = createContext(null) export const PickerContext = createContext(null) export const VarContext = createContext({}) + +export const IntersectionObserverContext = createContext(null) diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-scroll-view.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-scroll-view.tsx index 34e7a5dc18..a0c00a22fa 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-scroll-view.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-scroll-view.tsx @@ -33,12 +33,13 @@ */ import { ScrollView } from 'react-native-gesture-handler' import { View, RefreshControl, NativeSyntheticEvent, NativeScrollEvent, LayoutChangeEvent, ViewStyle } from 'react-native' -import { JSX, ReactNode, RefObject, useRef, useState, useEffect, forwardRef } from 'react' +import { JSX, ReactNode, RefObject, useRef, useState, useEffect, forwardRef, useContext } from 'react' import { useAnimatedRef } from 'react-native-reanimated' import { warn } from '@mpxjs/utils' import useInnerProps, { getCustomEvent } from './getInnerListeners' import useNodesRef, { HandlerRef } from './useNodesRef' import { splitProps, splitStyle, useTransformStyle, useLayout, wrapChildren } from './utils' +import { IntersectionObserverContext } from './context' interface ScrollViewProps { children?: ReactNode; @@ -60,6 +61,7 @@ interface ScrollViewProps { 'scroll-top'?: number; 'scroll-left'?: number; 'enable-offset'?: boolean; + 'enable-trigger-intersection-observer'?: boolean; 'enable-var'?: boolean; 'external-var-context'?: Record; 'parent-font-size'?: number; @@ -107,6 +109,7 @@ const _ScrollView = forwardRef, S 'scroll-x': scrollX = false, 'scroll-y': scrollY = false, 'enable-back-to-top': enableBackToTop = false, + 'enable-trigger-intersection-observer': enableTriggerIntersectionObserver = false, 'paging-enabled': pagingEnabled = false, 'upper-threshold': upperThreshold = 50, 'lower-threshold': lowerThreshold = 50, @@ -139,6 +142,7 @@ const _ScrollView = forwardRef, S const hasCallScrollToUpper = useRef(true) const hasCallScrollToLower = useRef(false) const initialTimeout = useRef | null>(null) + const intersectionObservers = useContext(IntersectionObserverContext) const { normalStyle, @@ -285,6 +289,11 @@ const _ScrollView = forwardRef, S }, props) ) updateScrollOptions(e, { scrollLeft, scrollTop }) + if (enableTriggerIntersectionObserver && intersectionObservers) { + for (const key in intersectionObservers) { + intersectionObservers[key].throttleMeasure() + } + } } function onScrollEnd (e: NativeSyntheticEvent) { @@ -407,6 +416,7 @@ const _ScrollView = forwardRef, S 'scroll-x', 'scroll-y', 'enable-back-to-top', + 'enable-trigger-intersection-observer', 'paging-enabled', 'show-scrollbar', 'upper-threshold', diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-swiper/carouse.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-swiper/carouse.tsx index 1fa74167b5..009894687d 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-swiper/carouse.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-swiper/carouse.tsx @@ -87,6 +87,8 @@ const _Carouse = forwardRef, Carouse const initOffsetIndex = initIndex + (props.circular && totalElements > 1 ? 1 : 0) const defaultX = (defaultWidth * initOffsetIndex) || 0 const defaultY = (defaultHeight * initOffsetIndex) || 0 + // 主动scorllTo时是否要出发onScrollEnd + const needTriggerScrollEnd = useRef(true) // 内部存储上一次的offset值 const autoplayTimerRef = useRef | null>(null) const scrollViewRef = useRef(null) @@ -100,22 +102,21 @@ const _Carouse = forwardRef, Carouse // 内部存储上一次的偏移量 const internalsRef = useRef({ offset: { - x: defaultX || 0, - y: defaultY || 0 + x: 0, + y: 0 }, isScrolling: false }) const isDragRef = useRef(false) const [state, setState] = useState({ - children: newChild, width: dir === 'x' && typeof defaultWidth === 'number' ? defaultWidth - previousMargin - nextMargin : defaultWidth, height: dir === 'y' && typeof defaultHeight === 'number' ? defaultHeight - previousMargin - nextMargin : defaultHeight, // 真正的游标索引, 从0开始 index: initIndex, total: totalElements, offset: { - x: dir === 'x' ? defaultX : 0, - y: dir === 'y' ? defaultY : 0 + x: 0, + y: 0 }, dir } as CarouseState) @@ -138,25 +139,38 @@ const _Carouse = forwardRef, Carouse useEffect(() => { // 确认这个是变化的props变化的时候才执行,还是初始化的时候就执行 - if (!props.autoplay && props.current !== state.index) { + if (!props.autoplay && props.current !== undefined && props.current !== state.index) { const initIndex = props.current || 0 // 这里要排除超过元素个数的设置 - const initOffsetIndex = initIndex + (props.circular && totalElements > 1 ? 1 : 0) - const defaultX = (defaultWidth * initOffsetIndex) || 0 - const offset = { - x: dir === 'x' ? defaultX : 0, - y: dir === 'y' ? defaultY : 0 + const { nextIndex, nextOffset } = getMultiNextConfig(props.current) + // 1. 安卓需要主动更新下内部状态, 2. IOS不能触发完wcrollTo之后立即updateState, 会造成滑动两次 + // 2. setTimeout 是fix 当再渲染过程中触发scrollTo失败的问题 + if (Platform.OS === 'ios') { + needTriggerScrollEnd.current = false + setTimeout(() => { + scrollViewRef.current?.scrollTo({ + ...nextOffset, + animated: true + }) + }, 50) + } else { + updateState(nextIndex, nextOffset) } - state.offset = offset - internalsRef.current.offset = offset - setState((preState) => { - return { - ...preState, - offset - } - }) } - }, [props.current]) + }, [props.current, state.width, state.height]) + + function getMultiNextConfig (target: number) { + const step = state.dir === 'x' ? state.width : state.height + const targetPos = step * props.current + const targetOffset = { + x: dir === 'x' ? targetPos : 0, + y: dir === 'y' ? targetPos : 0 + } + return { + nextIndex: target, + nextOffset: targetOffset + } + } /** * @desc: 更新状态: index和offset, 并响应索引变化的事件 * scrollViewOffset: 移动到的目标位置 @@ -208,7 +222,6 @@ const _Carouse = forwardRef, Carouse nextIndex = isBack ? nextIndex - 2 : nextIndex } if (!props.circular) { - // nextIndex = isBack ? nextIndex - 2 : nextIndex nextOffset = Object.assign({}, currentOffset, { [state.dir]: step * nextIndex }) } else { if (isBack) { @@ -254,13 +267,12 @@ const _Carouse = forwardRef, Carouse createAutoPlay() return } - if (!Array.isArray(state.children)) { + if (!Array.isArray(props.children)) { return } const step = state.dir === 'x' ? state.width : state.height const { nextOffset, autoMoveOffset, isAutoEnd } = getNextConfig(state.offset) // 这里可以scroll到下一个元素, 但是把scrollView的偏移量在设置为content,视觉效果就没了吧 - // scrollViewRef.current?.scrollTo({ x: nextOffset['x'], y: nextOffset['y'], animated: true }) if (Platform.OS === 'ios') { if (!isAutoEnd) { scrollViewRef.current?.scrollTo({ x: nextOffset.x, y: nextOffset.y, animated: true }) @@ -286,7 +298,6 @@ const _Carouse = forwardRef, Carouse // 安卓无法实现视觉的无缝连接, 只能回到真正的位置, 且安卓调用scrollTo不能触发onMomentumScrollEnd,还未找到为啥 if (state.dir === 'x') { scrollViewRef.current?.scrollTo({ x: step, y: step, animated: true }) - // scrollViewRef.current?.scrollTo({ x: autoMoveOffset.x, y: autoMoveOffset.y, animated: true }) } else { scrollViewRef.current?.scrollTo({ x: autoMoveOffset.x, y: step, animated: true }) } @@ -304,9 +315,15 @@ const _Carouse = forwardRef, Carouse /** * 当用户开始拖动结束 + * 注意: 当手动调用scrollTo的时候, 安卓不会触发onMomentumScrollEnd, IOS会触发onMomentumScrollEnd */ function onScrollEnd (event: NativeSyntheticEvent) { - // 这里安卓好像没有触发onScrollEnd, 调用scrollTo的时候 + if (Platform.OS === 'ios' && !needTriggerScrollEnd.current) { + const { nextIndex, nextOffset } = getMultiNextConfig(props.current) + updateState(nextIndex, nextOffset) + needTriggerScrollEnd.current = true + return + } if (totalElements === 1) { return } @@ -334,57 +351,41 @@ const _Carouse = forwardRef, Carouse * @desc: 水平方向时,获取元素的布局,更新, 其中如果传递100%时需要依赖measure计算元算的宽高 */ function onWrapperLayout (e: LayoutChangeEvent) { - if (hasSelfPercent) { - const { width, height } = e?.nativeEvent?.layout || {} - setWidth(width || 0) - setHeight(height || 0) - } - if (props.enableOffset) { - scrollViewRef.current?.measure((x: number, y: number, width: number, height: number, offsetLeft: number, offsetTop: number) => { - layoutRef.current = { x, y, width, height, offsetLeft, offsetTop } - const isWDiff = state.width !== width - const isHDiff = state.height !== height - if (isWDiff || isHDiff) { - const changeState = { - width: isWDiff ? width : state.width, - height: isHDiff ? height : state.height - } - const attr = state.dir === 'x' ? 'width' : 'height' - changeState[attr] = changeState[attr] - previousMargin - nextMargin - const correctOffset = Object.assign({}, state.offset, { - [state.dir]: initOffsetIndex * (state.dir === 'x' ? changeState.width : changeState.height) - }) - state.offset = correctOffset - state.width = changeState.width - state.height = changeState.height - setState((preState) => { - return { - ...preState, - offset: correctOffset, - width: changeState.width, - height: changeState.height - } - }) - scrollViewRef.current?.scrollTo({ x: correctOffset.x, y: correctOffset.y, animated: false }) + scrollViewRef.current?.measure((x: number, y: number, width: number, height: number, offsetLeft: number, offsetTop: number) => { + layoutRef.current = { x, y, width, height, offsetLeft, offsetTop } + const isWDiff = state.width !== width + const isHDiff = state.height !== height + if (isWDiff || isHDiff) { + const changeState = { + width: isWDiff ? width : state.width, + height: isHDiff ? height : state.height } - props.getInnerLayout && props.getInnerLayout(layoutRef) - }) - } + const attr = state.dir === 'x' ? 'width' : 'height' + changeState[attr] = changeState[attr] - previousMargin - nextMargin + const correctOffset = Object.assign({}, state.offset, { + [state.dir]: initOffsetIndex * (state.dir === 'x' ? changeState.width : changeState.height) + }) + state.width = changeState.width + state.height = changeState.height + // 这里setState之后,会再触发重新渲染, renderScrollView会再次触发onScrollEnd, + setState((preState) => { + return { + ...preState, + width: changeState.width, + height: changeState.height + } + }) + } + props.getInnerLayout && props.getInnerLayout(layoutRef) + }) } function getOffset (): Array { const step = state.dir === 'x' ? state.width : state.height if (!step || Number.isNaN(+step)) return [] const offsetArray = [] - if (previousMargin) { - offsetArray.push(0) - for (let i = 1; i < totalElements; i++) { - offsetArray.push(i * step - previousMargin) - } - } else { - for (let i = 0; i < totalElements; i++) { - offsetArray.push(i * step) - } + for (let i = 0; i < totalElements; i++) { + offsetArray.push(i * step) } return offsetArray } @@ -394,7 +395,7 @@ const _Carouse = forwardRef, Carouse const scrollElementProps = { ref: scrollViewRef, horizontal: props.horizontal, - pagingEnabled: false, + pagingEnabled: true, snapToOffsets: offsetsArray, decelerationRate: 0.99, // 'fast' showsHorizontalScrollIndicator: false, @@ -461,20 +462,21 @@ const _Carouse = forwardRef, Carouse } function renderPages () { - const { width, height, total, children } = state + const { width, height } = state + const { children } = props const { circular } = props const pageStyle = { width: width, height: height } // 设置了previousMargin或者nextMargin, // 1. 元素的宽度是减去这两个数目之和 // 2. previousMargin设置marginLeft正值, nextmargin设置marginRight负值 // 3. 第一个元素设置previousMargin 和 nextMargin, 最后一个元素 - if (total > 1 && Array.isArray(children)) { + if (totalElements > 1 && Array.isArray(children)) { let arrElements: (Array) = [] // pages = ["2", "0", "1", "2", "0"] const pages = Array.isArray(children) ? Object.keys(children) : [] /* 无限循环的时候 */ if (circular) { - pages.unshift(total - 1 + '') + pages.unshift(totalElements - 1 + '') pages.push('0') } arrElements = pages.map((page, i) => { @@ -486,7 +488,6 @@ const _Carouse = forwardRef, Carouse } else if (i === pages.length - 1 && typeof width === 'number') { nextMargin && (extraStyle.marginRight = nextMargin) } - // return ({children[+page]}) return ( {wrapChildren( { 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 44578c85eb..6ce4774000 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-view.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-view.tsx @@ -7,6 +7,9 @@ import { View, TextStyle, NativeSyntheticEvent, ViewProps, ImageStyle, ImageResizeMode, StyleSheet, Image, LayoutChangeEvent, Text } from 'react-native' import { useRef, useState, useEffect, forwardRef, ReactNode, JSX, Children, cloneElement } from 'react' import useInnerProps from './getInnerListeners' +import Animated from 'react-native-reanimated' +import useAnimationHooks from './useAnimationHooks' +import type { AnimationProp } from './useAnimationHooks' import { ExtendedViewStyle } from './types/common' import useNodesRef, { HandlerRef } from './useNodesRef' import { parseUrl, PERCENT_REGEX, splitStyle, splitProps, useTransformStyle, wrapChildren, useLayout } from './utils' @@ -14,6 +17,7 @@ import LinearGradient from 'react-native-linear-gradient' export interface _ViewProps extends ViewProps { style?: ExtendedViewStyle + animation?: AnimationProp children?: ReactNode | ReactNode[] 'hover-style'?: ExtendedViewStyle 'hover-start-time'?: number @@ -24,6 +28,7 @@ export interface _ViewProps extends ViewProps { 'parent-font-size'?: number 'parent-width'?: number 'parent-height'?: number + 'enable-animation'?: boolean bindtouchstart?: (event: NativeSyntheticEvent | unknown) => void bindtouchmove?: (event: NativeSyntheticEvent | unknown) => void bindtouchend?: (event: NativeSyntheticEvent | unknown) => void @@ -650,9 +655,11 @@ const _View = forwardRef, _ViewProps>((viewProps, r 'enable-var': enableVar, 'external-var-context': externalVarContext, 'enable-background': enableBackground, + 'enable-animation': enableAnimation, 'parent-font-size': parentFontSize, 'parent-width': parentWidth, - 'parent-height': parentHeight + 'parent-height': parentHeight, + animation } = props const [isHover, setIsHover] = useState(false) @@ -747,9 +754,10 @@ const _View = forwardRef, _ViewProps>((viewProps, r layoutProps } = useLayout({ props, hasSelfPercent, setWidth, setHeight, nodeRef }) + const viewStyle = Object.assign({}, innerStyle, layoutStyle) const innerProps = useInnerProps(props, { ref: nodeRef, - style: { ...innerStyle, ...layoutStyle }, + style: viewStyle, ...layoutProps, ...(hoverStyle && { bindtouchstart: onTouchStart, @@ -764,25 +772,37 @@ const _View = forwardRef, _ViewProps>((viewProps, r layoutRef }) - return ( - - { - wrapWithChildren( - props, - { - hasVarDec, - enableBackground: enableBackgroundRef.current, - textStyle, - backgroundStyle, - varContext: varContextRef.current, - textProps - } - ) - } - - ) + {childNode} + ) + : ( + {childNode} + ) }) _View.displayName = 'mpx-view' diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-web-view.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-web-view.tsx index fae56a20ab..a3161e0348 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-web-view.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-web-view.tsx @@ -47,7 +47,7 @@ interface FormRef { } const _WebView = forwardRef, WebViewProps>((props, ref): JSX.Element => { - const { src, bindmessage = noop, bindload = noop, binderror = noop } = props + const { src = '', bindmessage = noop, bindload = noop, binderror = noop } = props if (props.style) { warn('The web-view component does not support the style prop.') } diff --git a/packages/webpack-plugin/lib/runtime/components/react/types/common.ts b/packages/webpack-plugin/lib/runtime/components/react/types/common.ts index 849998fe11..50937a8faf 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/types/common.ts +++ b/packages/webpack-plugin/lib/runtime/components/react/types/common.ts @@ -10,6 +10,7 @@ export type ExtendedViewStyle = ViewStyle & { borderRadius?: string | number backgroundPosition?: backgroundPositionList [key: string]: any + transform?: {[key: string]: number | string}[] } export type ExtendedFunctionComponent = FunctionComponent & { diff --git a/packages/webpack-plugin/lib/runtime/components/react/useAnimationHooks.ts b/packages/webpack-plugin/lib/runtime/components/react/useAnimationHooks.ts new file mode 100644 index 0000000000..2b08317e6e --- /dev/null +++ b/packages/webpack-plugin/lib/runtime/components/react/useAnimationHooks.ts @@ -0,0 +1,248 @@ +import { useEffect, useMemo, useRef } from 'react' +import { TransformsStyle } from 'react-native' +import { + Easing, + useSharedValue, + withTiming, + useAnimatedStyle, + withSequence, + withDelay, + makeMutable, + cancelAnimation, + SharedValue, + WithTimingConfig, + AnimationCallback +} from 'react-native-reanimated' +import { ExtendedViewStyle } from './types/common' +import type { _ViewProps } from './mpx-view' + +// type TransformKey = 'translateX' | 'translateY' | 'rotate' | 'rotateX' | 'rotateY' | 'rotateZ' | 'scaleX' | 'scaleY' | 'skewX' | 'skewY' +// type NormalKey = 'opacity' | 'backgroundColor' | 'width' | 'height' | 'top' | 'right' | 'bottom' | 'left' | 'transformOrigin' +// type RuleKey = TransformKey | NormalKey +type AnimatedOption = { + duration: number + delay: number + useNativeDriver: boolean + timingFunction: 'linear' | 'ease' | 'ease-in' | 'ease-in-out'| 'ease-out' + transformOrigin: string +} +type ExtendWithTimingConfig = WithTimingConfig & { + delay: number +} +export type AnimationStepItem = { + animatedOption: AnimatedOption + rules: Map + transform: Map +} +export type AnimationProp = { + id: number, + actions: AnimationStepItem[] +} + +// 微信 timingFunction 和 RN Easing 对应关系 +const EasingKey = { + linear: Easing.linear, + ease: Easing.ease, + 'ease-in': Easing.in(Easing.ease), + 'ease-in-out': Easing.inOut(Easing.ease), + 'ease-out': Easing.out(Easing.ease) + // 'step-start': '', + // 'step-end': '' +} +const TransformInitial: ExtendedViewStyle = { + // matrix: 0, + // matrix3d: 0, + rotate: '0deg', + rotateX: '0deg', + rotateY: '0deg', + rotateZ: '0deg', + // rotate3d:[0,0,0] + scale: 1, + // scale3d: [1, 1, 1], + scaleX: 1, + scaleY: 1, + // scaleZ: 1, + skew: 0, + skewX: '0deg', + skewY: '0deg', + translate: 0, + // translate3d: 0, + translateX: 0, + translateY: 0 + // translateZ: 0, +} +// 动画默认初始值 +const InitialValue: ExtendedViewStyle = Object.assign({ + opacity: 1, + backgroundColor: 'transparent', + width: 0, + height: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, + transformOrigin: ['50%', '50%', 0] +}, TransformInitial) +const TransformOrigin = 'transformOrigin' +// deg 角度 +// const isDeg = (key: RuleKey) => ['rotateX', 'rotateY', 'rotateZ', 'rotate', 'skewX', 'skewY'].includes(key) +// 背景色 +// const isBg = (key: RuleKey) => key === 'backgroundColor' +// transform +const isTransform = (key: string) => Object.keys(TransformInitial).includes(key) + +export default function useAnimationHooks (props: _ViewProps) { + const { style: originalStyle = {}, animation } = props + // id 标识 + const id = animation?.id || -1 + // 有动画样式的 style key + const animatedStyleKeys = useSharedValue([] as (string|string[])[]) + const animatedKeys = useRef({} as {[propName: keyof ExtendedViewStyle]: Boolean}) + // ** 全量 style prop sharedValue + // 不能做增量的原因: + // 1 尝试用 useRef,但 useAnimatedStyle 访问后的 ref 不能在增加新的值,被冻结 + // 2 尝试用 useSharedValue,因为实际触发的 style prop 需要是 sharedValue 才能驱动动画,若外层 shareValMap 也是 sharedValue,动画无法驱动。 + const shareValMap = useMemo(() => { + return Object.keys(InitialValue).reduce((valMap, key) => { + const defaultVal = getInitialVal(key, isTransform(key)) + valMap[key] = makeMutable(defaultVal) + return valMap + }, {} as { [propName: keyof ExtendedViewStyle]: SharedValue }) + }, []) + // ** 获取动画样式prop & 驱动动画 + useEffect(() => { + if (id === -1) return + // 更新动画样式 key map + animatedKeys.current = getAnimatedStyleKeys() + const keys = Object.keys(animatedKeys.current) + animatedStyleKeys.value = formatAnimatedKeys([TransformOrigin, ...keys]) + // 驱动动画 + createAnimation(keys) + }, [id]) + // ** 清空动画 + useEffect(() => { + return () => { + Object.values(shareValMap).forEach((value) => { + cancelAnimation(value) + }) + } + }, []) + // 根据 animation action 创建&驱动动画 key => wi + function createAnimation (animatedKeys: string[] = []) { + const actions = animation?.actions || [] + const sequence = {} as { [propName: keyof ExtendedViewStyle]: (string|number)[] } + const lastValueMap = {} as { [propName: keyof ExtendedViewStyle]: string|number } + actions.forEach(({ animatedOption, rules, transform }, index) => { + const { delay, duration, timingFunction, transformOrigin } = animatedOption + const easing = EasingKey[timingFunction] || Easing.inOut(Easing.quad) + let needSetCallback = true + const setTransformOrigin: AnimationCallback = (finished: boolean) => { + 'worklet' + // 动画结束后设置下一次transformOrigin + if (finished) { + if (index < actions.length - 1) { + const transformOrigin = actions[index + 1].animatedOption?.transformOrigin + transformOrigin && (shareValMap[TransformOrigin].value = transformOrigin) + } + } + } + if (index === 0) { + // 设置当次中心 + shareValMap[TransformOrigin].value = transformOrigin + } + // 添加每个key的多次step动画 + animatedKeys.forEach(key => { + let toVal = (rules.get(key) || transform.get(key)) as number|string + // key不存在,第一轮取shareValMap[key]value,非第一轮取上一轮的 + if (!toVal) { + toVal = index > 0 ? lastValueMap[key] : shareValMap[key].value + } + const animation = getAnimation({ key, value: toVal }, { delay, duration, easing }, needSetCallback ? setTransformOrigin : undefined) + needSetCallback = false + if (!sequence[key]) { + sequence[key] = [animation] + } else { + sequence[key].push(animation) + } + // 更新一下 lastValueMap + lastValueMap[key] = toVal + }) + // 赋值驱动动画 + animatedKeys.forEach((key) => { + const animations = sequence[key] + shareValMap[key].value = withSequence(...animations) + }) + }) + } + // 创建单个animation + function getAnimation ({ key, value }: { key: string, value: string|number }, { delay, duration, easing }: ExtendWithTimingConfig, callback?: AnimationCallback) { + const animation = typeof callback === 'function' + ? withTiming(value, { duration, easing }, callback) + : withTiming(value, { duration, easing }) + return delay ? withDelay(delay, animation) : animation + } + // 获取初始值(prop style or 默认值) + function getInitialVal (key: keyof ExtendedViewStyle, isTransform = false) { + if (isTransform && originalStyle.transform?.length) { + let initialVal = InitialValue[key] + // 仅支持 { transform: [{rotateX: '45deg'}, {rotateZ: '0.785398rad'}] } 格式的初始样式 + originalStyle.transform.forEach(item => { + if (item[key] !== undefined) initialVal = item[key] + }) + return initialVal + } + return originalStyle[key] === undefined ? InitialValue[key] : originalStyle[key] + } + // 循环 animation actions 获取所有有动画的 style prop name + function getAnimatedStyleKeys () { + return (animation?.actions || []).reduce((keyMap, action) => { + const { rules, transform } = action + const ruleArr = [...rules.keys(), ...transform.keys()] + ruleArr.forEach(key => { + if (!keyMap[key]) keyMap[key] = true + }) + return keyMap + }, animatedKeys.current) + } + // animated key transform 格式化 + function formatAnimatedKeys (keys: string[] = []) { + const animatedKeys = [] as (string|string[])[] + const transforms = [] as string[] + keys.forEach(key => { + if (isTransform(key)) { + transforms.push(key) + } else { + animatedKeys.push(key) + } + }) + if (transforms.length) animatedKeys.push(transforms) + return animatedKeys + } + // transform 数组转对象 + function getTransformObj () { + 'worklet' + const transforms = originalStyle.transform || [] + return transforms.reduce((transformObj, item) => { + return Object.assign(transformObj, item) + }, {} as { [propName: string]: string | number }) + } + // ** 生成动画样式 + return useAnimatedStyle(() => { + // console.info(`useAnimatedStyle styles=`, originalStyle) + return animatedStyleKeys.value.reduce((styles, key) => { + // console.info('getAnimationStyles', key, shareValMap[key].value) + if (Array.isArray(key)) { + const transformStyle = getTransformObj() + key.forEach((transformKey) => { + transformStyle[transformKey] = shareValMap[transformKey].value + }) + styles.transform = Object.entries(transformStyle).map(([key, value]) => { + return { [key]: value } + }) as Extract<'transform', TransformsStyle> + } else { + styles[key] = shareValMap[key].value + } + return styles + }, Object.assign({}, originalStyle) as ExtendedViewStyle) + }) +} diff --git a/packages/webpack-plugin/package.json b/packages/webpack-plugin/package.json index 911be86874..9781cb26e4 100644 --- a/packages/webpack-plugin/package.json +++ b/packages/webpack-plugin/package.json @@ -88,6 +88,7 @@ "@types/react": "^18.2.79", "react-native": "^0.74.5", "react-native-gesture-handler": "^2.18.1", + "react-native-reanimated": "^3.15.2", "react-native-linear-gradient": "^2.8.3", "react-native-safe-area-context": "^4.12.0", "react-native-webview": "^13.12.2",