├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── src ├── adapter │ ├── XMLHttpRequest.js │ └── index.js ├── index.d.ts └── index.js └── webpack.config.js /.eslintignore: -------------------------------------------------------------------------------- 1 | src/index.js 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "babel-eslint", 4 | parserOptions: { 5 | ecmaVersion: 2018, 6 | sourceType: 'module' 7 | }, 8 | env: { 9 | es6: true, 10 | node: true 11 | }, 12 | extends: [ 13 | "eslint:recommended" 14 | ], 15 | globals: { 16 | 'XMLHttpRequest': true, 17 | 'window': true, 18 | 'document': true, 19 | 'navigator': true, 20 | 'wx': true 21 | }, 22 | rules: { 23 | 'no-console': process.env.NODE_ENV !== 'production' ? 0 : 2, 24 | 'no-useless-escape': 0, 25 | 'no-empty': 0 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | miniprogram_dist 3 | miniprogram_dev 4 | dist 5 | miniprogram_dist 6 | 7 | .DS_Store -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 wechat-miniprogram 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lottie for MiniProgram 2 | 3 | [![](https://img.shields.io/npm/v/lottie-miniprogram)](https://www.npmjs.com/package/lottie-miniprogram) 4 | [![](https://img.shields.io/npm/l/lottie-miniprogram)](https://github.com/wechat-miniprogram/lottie-miniprogram) 5 | 6 | lottie 动画库适配小程序的版本。 7 | 8 | > lottie 的相关介绍与动画生成方法等请参考[官方说明](https://github.com/airbnb/lottie-web) 9 | 10 | > 依赖小程序基础库版本 >= 2.8.0 的环境 11 | 12 | ## 使用 13 | 14 | 可参考该代码片段:[https://developers.weixin.qq.com/s/2TYvm9mJ75bF](https://developers.weixin.qq.com/s/2TYvm9mJ75bF)。大致步骤如下: 15 | 16 | 1. 通过 npm 安装: 17 | ``` 18 | npm install --save lottie-miniprogram 19 | ``` 20 | 21 | 2. 传入 canvas 对象用于适配 22 | ``` 23 | 24 | ``` 25 | ``` 26 | import lottie from 'lottie-miniprogram' 27 | 28 | Page({ 29 | onReady() { 30 | this.createSelectorQuery().select('#canvas').node(res => { 31 | const canvas = res.node 32 | lottie.setup(canvas) 33 | }).exec() 34 | } 35 | }) 36 | ``` 37 | 38 | 3. 使用 lottie 接口 39 | ``` 40 | lottie.setup(canvas) 41 | this.ani = lottie.loadAnimation({ 42 | ... 43 | }) 44 | this.ani.destroy() // 页面退出需销毁 45 | ``` 46 | 47 | ## 接口 48 | 49 | 目前提供两个接口: 50 | 51 | #### lottie.setup(canvas) 52 | 需要在任何 lottie 接口调用之前调用,传入 canvas 对象 53 | 54 | #### lottie.loadAnimation(options) 55 | 与原来的 [loadAnimation](https://github.com/airbnb/lottie-web/wiki/loadAnimation-options) 有些不同,支持的参数有: 56 | * loop 57 | * autoplay 58 | * animationData 59 | * path (只支持网络地址) 60 | * rendererSettings.context (必填) 61 | 62 | ## 说明 63 | * 本项目是以 npm 的方式依赖原 lottie-web 项目,若原项目有新版本,可直接改变依赖的版本号。 64 | * 本项目依赖小程序基础库 2.8.0 里性能更好的 canvas 实现,由于还有些小问题~~没有正式开放~~(2.9.0 已正式对外),但目前用在此处暂无发现问题。 65 | * 由于小程序本身不支持动态执行脚本,因此 lottie 的 expression 功能也是不支持的。 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lottie-miniprogram", 3 | "version": "1.0.12", 4 | "description": "lottie for miniprogram", 5 | "main": "miniprogram_dist/index.js", 6 | "scripts": { 7 | "dev": "webpack --mode=development", 8 | "build": "webpack --mode=production", 9 | "lint": "eslint \"src/**/*.js\"" 10 | }, 11 | "miniprogram": "miniprogram_dist", 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/wechat-miniprogram/lottie-miniprogram.git" 15 | }, 16 | "keywords": [ 17 | "lottie", 18 | "miniprogram" 19 | ], 20 | "author": "wechat-miniprogram", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/wechat-miniprogram/lottie-miniprogram/issues" 24 | }, 25 | "homepage": "https://github.com/wechat-miniprogram/lottie-miniprogram#readme", 26 | "devDependencies": { 27 | "@babel/core": "^7.5.5", 28 | "@babel/plugin-proposal-class-properties": "^7.5.5", 29 | "@babel/preset-env": "^7.5.5", 30 | "babel-eslint": "^10.0.2", 31 | "babel-loader": "^8.0.6", 32 | "eslint": "^6.1.0", 33 | "eslint-loader": "^2.2.1", 34 | "string-replace-loader": "^2.2.0", 35 | "webpack": "^4.39.1", 36 | "webpack-cli": "^3.3.6", 37 | "lottie-web": "^5.7.4", 38 | "copy-webpack-plugin": "^6.0.3" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/adapter/XMLHttpRequest.js: -------------------------------------------------------------------------------- 1 | const _url = new WeakMap() 2 | const _method = new WeakMap() 3 | const _requestHeader = new WeakMap() 4 | const _responseHeader = new WeakMap() 5 | const _requestTask = new WeakMap() 6 | 7 | function _triggerEvent(type, ...args) { 8 | if (typeof this[`on${type}`] === 'function') { 9 | this[`on${type}`].apply(this, args) 10 | } 11 | } 12 | 13 | function _changeReadyState(readyState) { 14 | this.readyState = readyState 15 | _triggerEvent.call(this, 'readystatechange') 16 | } 17 | 18 | export default class XMLHttpRequest { 19 | // TODO 没法模拟 HEADERS_RECEIVED 和 LOADING 两个状态 20 | static UNSEND = 0 21 | static OPENED = 1 22 | static HEADERS_RECEIVED = 2 23 | static LOADING = 3 24 | static DONE = 4 25 | 26 | /* 27 | * TODO 这一批事件应该是在 XMLHttpRequestEventTarget.prototype 上面的 28 | */ 29 | onabort = null 30 | onerror = null 31 | onload = null 32 | onloadstart = null 33 | onprogress = null 34 | ontimeout = null 35 | onloadend = null 36 | 37 | onreadystatechange = null 38 | readyState = 0 39 | response = null 40 | responseText = null 41 | responseType = '' 42 | responseXML = null 43 | status = 0 44 | statusText = '' 45 | upload = {} 46 | withCredentials = false 47 | 48 | constructor() { 49 | _requestHeader.set(this, { 50 | 'content-type': 'application/x-www-form-urlencoded' 51 | }) 52 | _responseHeader.set(this, {}) 53 | } 54 | 55 | abort() { 56 | const myRequestTask = _requestTask.get(this) 57 | 58 | if (myRequestTask) { 59 | myRequestTask.abort() 60 | } 61 | } 62 | 63 | getAllResponseHeaders() { 64 | const responseHeader = _responseHeader.get(this) 65 | 66 | return Object.keys(responseHeader).map((header) => { 67 | return `${header}: ${responseHeader[header]}` 68 | }).join('\n') 69 | } 70 | 71 | getResponseHeader(header) { 72 | return _responseHeader.get(this)[header] 73 | } 74 | 75 | open(method, url/* async, user, password 这几个参数在小程序内不支持*/) { 76 | _method.set(this, method) 77 | _url.set(this, url) 78 | _changeReadyState.call(this, XMLHttpRequest.OPENED) 79 | } 80 | 81 | overrideMimeType() { 82 | } 83 | 84 | send(data = '') { 85 | if (this.readyState !== XMLHttpRequest.OPENED) { 86 | throw new Error("Failed to execute 'send' on 'XMLHttpRequest': The object's state must be OPENED.") 87 | } else { 88 | wx.request({ 89 | data, 90 | url: _url.get(this), 91 | method: _method.get(this), 92 | header: _requestHeader.get(this), 93 | // responseType: this.responseType, 94 | success: ({ data, statusCode, header }) => { 95 | if (typeof data !== 'string' && !(data instanceof ArrayBuffer)) { 96 | try { 97 | data = JSON.stringify(data) 98 | } catch (e) { 99 | } 100 | } 101 | 102 | this.status = statusCode 103 | _responseHeader.set(this, header) 104 | _triggerEvent.call(this, 'loadstart') 105 | _changeReadyState.call(this, XMLHttpRequest.HEADERS_RECEIVED) 106 | _changeReadyState.call(this, XMLHttpRequest.LOADING) 107 | 108 | this.response = data 109 | 110 | if (data instanceof ArrayBuffer) { 111 | this.responseText = '' 112 | const bytes = new Uint8Array(data) 113 | const len = bytes.byteLength 114 | 115 | for (let i = 0; i < len; i++) { 116 | this.responseText += String.fromCharCode(bytes[i]) 117 | } 118 | } else { 119 | this.responseText = data 120 | } 121 | _changeReadyState.call(this, XMLHttpRequest.DONE) 122 | _triggerEvent.call(this, 'load') 123 | _triggerEvent.call(this, 'loadend') 124 | }, 125 | fail: ({ errMsg }) => { 126 | // TODO 规范错误 127 | if (errMsg.indexOf('abort') !== -1) { 128 | _triggerEvent.call(this, 'abort') 129 | } else { 130 | _triggerEvent.call(this, 'error', errMsg) 131 | } 132 | _triggerEvent.call(this, 'loadend') 133 | } 134 | }) 135 | } 136 | } 137 | 138 | setRequestHeader(header, value) { 139 | const myHeader = _requestHeader.get(this) 140 | 141 | myHeader[header] = value 142 | _requestHeader.set(this, myHeader) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/adapter/index.js: -------------------------------------------------------------------------------- 1 | import XHR from './XMLHttpRequest' 2 | 3 | function noop() {} 4 | 5 | function notSupport() { 6 | console.error('小程序由于不支持动态创建 canvas 的能力,故 lottie 中有关图片处理的操作无法支持,请保持图片的原始宽高与 JSON 描述的一致,避免需要对图片处理') 7 | } 8 | 9 | function createImg(canvas) { 10 | if (typeof canvas.createImage === 'undefined') { 11 | // TODO the return value should be replaced after setupLottie 12 | return {} 13 | } 14 | const img = canvas.createImage() 15 | img.addEventListener = img.addEventListener || function (evtName, callback) { 16 | if (evtName === 'load') { 17 | img.onload = function () { 18 | setTimeout(callback, 0) 19 | } 20 | } else if (evtName === 'error') { 21 | img.onerror = callback 22 | } 23 | } 24 | return img 25 | } 26 | 27 | function createElement(tagName) { 28 | if (tagName === 'canvas') { 29 | console.warn('发现 Lottie 动态创建 canvas 组件,但小程序不支持动态创建组件,接下来可能会出现异常') 30 | return { 31 | getContext: function () { 32 | return { 33 | fillRect: noop, 34 | createImage: notSupport, 35 | drawImage: notSupport, 36 | } 37 | }, 38 | } 39 | } else if (tagName === 'img') { 40 | return createImg(this) 41 | } 42 | } 43 | 44 | function wrapSetLineDash(ctx, originalSetLineDash) { 45 | return function setLineDash(segments) { 46 | return originalSetLineDash.call(ctx, Array.from(segments)) 47 | } 48 | } 49 | 50 | function wrapFill(ctx, originalFill) { 51 | return function fill() { 52 | // ignore parameters which causes iOS wechat 7.0.5 crash. 53 | return originalFill.call(ctx) 54 | } 55 | } 56 | 57 | function wrapMethodFatory(ctx, methodName, wrappedMethod) { 58 | const originalMethod = ctx[methodName] 59 | ctx[methodName] = wrappedMethod(ctx, originalMethod) 60 | } 61 | 62 | const systemInfo = wx.getSystemInfoSync() 63 | const g = { 64 | requestAnimationFrame(cb) { 65 | setTimeout(() => { 66 | typeof cb === 'function' && cb(Date.now()) 67 | }, 16) 68 | }, 69 | } 70 | 71 | g.window = { 72 | devicePixelRatio: systemInfo.pixelRatio, 73 | } 74 | g.document = g.window.document = { 75 | body: {}, 76 | createElement, 77 | } 78 | g.navigator = g.window.navigator = { 79 | userAgent: '' 80 | } 81 | 82 | XMLHttpRequest = XHR 83 | 84 | export const setup = (canvas) => { 85 | const {window, document} = g 86 | g._requestAnimationFrame = window.requestAnimationFrame 87 | g._cancelAnimationFrame = window.cancelAnimationFrame 88 | // lottie 对象是单例,内部状态(_stopped)在多页面下会混乱,保持 rAF 持续运行可规避 89 | window.requestAnimationFrame = function requestAnimationFrame(cb) { 90 | let called = false 91 | setTimeout(() => { 92 | if (called) return 93 | called = true 94 | typeof cb === 'function' && cb(Date.now()) 95 | }, 100) 96 | canvas.requestAnimationFrame((timeStamp) => { 97 | if (called) return 98 | called = true 99 | typeof cb === 'function' && cb(timeStamp) 100 | }) 101 | } 102 | window.cancelAnimationFrame = canvas.cancelAnimationFrame.bind(canvas) 103 | 104 | g._body = document.body 105 | g._createElement = document.createElement 106 | document.body = {} 107 | document.createElement = createElement.bind(canvas) 108 | 109 | const ctx = canvas.getContext('2d') 110 | if (!ctx.canvas) { 111 | ctx.canvas = canvas 112 | } 113 | 114 | wrapMethodFatory(ctx, 'setLineDash', wrapSetLineDash) 115 | wrapMethodFatory(ctx, 'fill', wrapFill) 116 | } 117 | 118 | export const restore = () => { 119 | const {window, document} = g 120 | window.requestAnimationFrame = g._requestAnimationFrame 121 | window.cancelAnimationFrame = g._cancelAnimationFrame 122 | document.body = g._body 123 | document.createElement = g._createElement 124 | } 125 | 126 | export {g} 127 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | type BaseRendererConfig = { 2 | imagePreserveAspectRatio?: string; 3 | className?: string; 4 | }; 5 | 6 | type CanvasRendererConfig = BaseRendererConfig & { 7 | clearCanvas?: boolean; 8 | context: CanvasRenderingContext2D; 9 | progressiveLoad?: boolean; 10 | preserveAspectRatio?: string; 11 | }; 12 | 13 | interface LoadAnimationParameter { 14 | renderer?: 'canvas'; 15 | loop?: boolean | number; 16 | autoplay?: boolean; 17 | name?: string; 18 | rendererSettings?: CanvasRendererConfig; 19 | animationData?: any; 20 | path?: string; 21 | } 22 | 23 | type AnimationDirection = 1 | -1; 24 | type AnimationSegment = [number, number]; 25 | type AnimationEventName = 'enterFrame' | 'loopComplete' | 'complete' | 'segmentStart' | 'destroy'; 26 | type AnimationEventCallback = (args: T) => void; 27 | 28 | interface LoadAnimationReturnType { 29 | play(): void; 30 | stop(): void; 31 | pause(): void; 32 | setSpeed(speed: number): void; 33 | goToAndPlay(value: number, isFrame?: boolean): void; 34 | goToAndStop(value: number, isFrame?: boolean): void; 35 | setDirection(direction: AnimationDirection): void; 36 | playSegments(segments: AnimationSegment | AnimationSegment[], forceFlag?: boolean): void; 37 | setSubframe(useSubFrames: boolean): void; 38 | destroy(): void; 39 | getDuration(inFrames?: boolean): number; 40 | triggerEvent(name: AnimationEventName, args: T): void; 41 | addEventListener(name: AnimationEventName, callback: AnimationEventCallback): void; 42 | removeEventListener(name: AnimationEventName, callback: AnimationEventCallback): void; 43 | } 44 | 45 | declare module lottie { 46 | var loadAnimation: (options: LoadAnimationParameter) => LoadAnimationReturnType; 47 | var setup: (node: any) => void; 48 | } 49 | 50 | export default lottie; 51 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import {setup, g, restore} from './adapter' 2 | const {window, document, navigator} = g 3 | 4 | ;'__LOTTIE_CANVAS__' 5 | 6 | function loadAnimation(options) { 7 | ['wrapper', 'container'].forEach(key => { 8 | if (key in options) { 9 | throw new Error(`Not support '${key}' parameter in miniprogram version of lottie.`) 10 | } 11 | }) 12 | if (typeof options.path === 'string' && !/^https?\:\/\//.test(options.path)) { 13 | throw new Error(`The 'path' is only support http protocol.`) 14 | } 15 | if (!options.rendererSettings || !options.rendererSettings.context) { 16 | throw new Error(`Parameter 'rendererSettings.context' should be a CanvasRenderingContext2D.`) 17 | } 18 | options.renderer = 'canvas' 19 | 20 | const _aniItem = window.lottie.loadAnimation(options) 21 | // try to fix https://github.com/airbnb/lottie-web/issues/1772 22 | const originalDestroy = _aniItem.destroy.bind(_aniItem) 23 | _aniItem.destroy = function () { 24 | // 恢复到上一次 canvas 的环境,避免当前 canvas 被销毁后导致 lottie-web 内部死锁 25 | restore() 26 | if (_aniItem.renderer && !_aniItem.renderer.destroyed) { 27 | _aniItem.renderer.renderConfig.clearCanvas = false 28 | } 29 | originalDestroy() 30 | }.bind(_aniItem) 31 | 32 | return _aniItem 33 | } 34 | 35 | const {freeze, unfreeze} = window.lottie 36 | 37 | export { 38 | setup, 39 | loadAnimation, 40 | freeze, 41 | unfreeze, 42 | } 43 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | const fs = require('fs') 4 | const CopyPlugin = require('copy-webpack-plugin') 5 | 6 | module.exports = { 7 | entry: './src/index.js', 8 | output: { 9 | libraryTarget: 'commonjs', 10 | filename: 'index.js', 11 | path: path.resolve(__dirname, 'miniprogram_dist'), 12 | }, 13 | devtool: '', 14 | module: { 15 | rules: [{ 16 | test: /\.js$/i, 17 | use: [{ 18 | loader: 'eslint-loader', 19 | }, { 20 | loader: 'babel-loader', 21 | options: { 22 | presets: ['@babel/preset-env'], 23 | plugins: ['@babel/plugin-proposal-class-properties'], 24 | }, 25 | }, { 26 | loader: 'string-replace-loader', 27 | options: { 28 | multiple: [{ 29 | search: `'__LOTTIE_CANVAS__'`, 30 | replace: fs.readFileSync('./node_modules/lottie-web/build/player/lottie_canvas.js', {encoding: 'utf8'}), 31 | }, { 32 | search: '__[STANDALONE]__', 33 | replace: '', 34 | }] 35 | } 36 | }], 37 | exclude: /node_modules/ 38 | }] 39 | }, 40 | amd: false, 41 | plugins: [ 42 | new webpack.DefinePlugin({ 43 | 'define': {} 44 | }), 45 | new webpack.optimize.ModuleConcatenationPlugin(), 46 | new CopyPlugin({ 47 | patterns: [ 48 | { 49 | from: path.resolve(__dirname, 'src', 'index.d.ts'), 50 | to: path.resolve(__dirname, 'miniprogram_dist', 'index.d.ts') 51 | }, 52 | ], 53 | }), 54 | ], 55 | } 56 | --------------------------------------------------------------------------------