文章开始前先做个简单的声明:这篇文章主要面向刚了解到 painter 的开发者,文中使用的框架、实现的方式只作为一种参考,并不一定是最佳实践。而使用 painter 能够做的扩展不止下文提到的这些能力,只是以下样例更为方便理解。欢迎各位酌情阅读。
自动态模版功能发布后,陆续有开发者开始尝试使用动态模版能力,我们也收集到了大家反馈的一些问题。这一系列文章的主要内容是从头开始实现一个简单的、基于 painter 动态模版能力的海报编辑工具。希望能通过这一过程,让大家了解为什么我们推出了动态模版能力,以及如何快速上手。同时在文中,也会统一回答一下关于动态模版使用的一些问题。文章中实现的编辑器代码,可以在https://github.com/Kujiale-Mobile/Taro-Painter-Demo/tree/2.x获取
先期准备
本次我们使用 2.2.15 版本的 taro 创建一个空项目
$ taro init
painter 组件是使用了 mina-painter 包(https://www.npmjs.com/package/mina-painter),这是我们封装的 taro 风格组件,供 taro 1.x/2.x 版本使用,支持 base64 图片与 canvas2d 模式。
$ yarn add mina-painter
创建空页面,并引入 painter 组件。
// pages/index/index.tsx
import Painter from 'mina-painter';
...
render() {
return (
...
<Painter
customStyle={`margin-top:5vh;`}
customActionStyle={customActionStyle}
dancePalette={danceTemplate}
palette={outputTemplate}
action={action}
clearActionBox={clearActionBox}
onImgOK={this.handleImgOk}
onDidShow={this.handleDidShow}
onTouchEnd={this.handleTouchEnd}
onViewClicked={this.handleViewClick}
onViewUpdate={this.handleViewUpdate}
/>
...
)
}
写一个简单的海报模版,包含 painter 内各种 view 类型。
// palette/index.ts
const template = {
width: '750rpx',
height: '1334rpx',
background: '#FFFFFF',
views: [
{
id: 'rect_10',
type: 'rect',
css: {
scalable: true,
color: '#F5F2EC',
height: '348rpx',
width: '750rpx',
bottom: '0rpx',
left: '0rpx',
minWidth: '80rpx',
minHeight: '80rpx',
},
},
{
id: 'rect_9',
type: 'rect',
css: {
scalable: true,
color: '#CBBD9F',
height: '646rpx',
width: '388rpx',
left: '0rpx',
top: '456rpx',
minWidth: '80rpx',
minHeight: '80rpx',
},
},
{
id: 'rect_8',
type: 'rect',
css: {
scalable: true,
color: '#EBE5D7',
height: '160rpx',
width: '360rpx',
top: '222rpx',
right: '0rpx',
minWidth: '80rpx',
minHeight: '80rpx',
},
},
{
id: 'qrcode',
type: 'image',
url: 'https://qhstaticssl.kujiale.com/newt/100082/image/jpeg/1623053391518/3EF9BB7ABE024959EB2A0E81078B40FA.jpeg',
css: {
width: '202rpx',
height: '202rpx',
bottom: '76rpx',
right: '40rpx',
borderRadius: '8rpx',
borderColor: '#FFFFFF',
borderWidth: '4rpx',
},
},
{
id: 'worker_type',
type: 'text',
text: '门店店长',
css: {
scalable: true,
deletable: true,
left: '156rpx',
bottom: '76rpx',
fontSize: '24rpx',
color: '#656c75',
lineHeight: '34rpx',
},
},
{
id: 'worker_name',
type: 'text',
text: 'tester',
css: {
scalable: true,
deletable: true,
fontSize: '30rpx',
fontWeight: 'bold',
color: '#333',
left: '156rpx',
bottom: '114rpx',
width: '280rpx',
lineHeight: '42rpx',
maxLines: 1,
},
},
{
id: 'avatar',
type: 'image',
url: 'https://qhstaticssl.kujiale.com/newt/100082/image/png/1623053110600/BDA064C5ECDCB7DD50DEB466C70E2EB0.png',
css: {
width: '80rpx',
height: '80rpx',
borderRadius: '40rpx',
left: '52rpx',
bottom: '76rpx',
},
},
{
type: 'image',
id: 'image-main',
url: 'https://qhstaticssl.kujiale.com/newt/100082/image/jpeg/1623053489433/54EE335A9C385A3D99D8664CB9135F84.jpg',
css: {
width: '672rpx',
height: '672rpx',
mode: 'aspectFill',
right: '0rpx',
top: '314rpx',
scalable: true,
minWidth: '120rpx',
},
},
{
type: 'rect',
css: {
width: '666rpx',
height: '2rpx',
top: '144rpx',
right: '0rpx',
color: '#EBEFF5',
},
},
{
id: 'name',
type: 'text',
text: '一个蒙着红色布的——球?',
css: {
scalable: true,
deletable: true,
fontSize: '32rpx',
color: '#383c42',
maxLines: 1,
width: '480rpx',
left: '76rpx',
top: '74rpx',
lineHeight: '44rpx',
},
},
{
id: 'product',
type: 'text',
text: '¥9999',
css: {
scalable: true,
deletable: true,
fontSize: '80rpx',
lineHeight: '90rpx',
fontWeight: 'bold',
color: '#383C42',
textAlign: 'center',
left: '76rpx',
top: '170rpx',
},
},
],
}
以上种种准备好之后,我们就能得到这样的一个页面:
这个页面有最基础的点选、拖动能力,通过配置 view.css 的 scalable 和 deletable 属性,可以使用 painter 内置提供的缩放功能。
怎么样,是不是已经功能很完备,好像可以满足需求了啊~好,今天的分享就到此为止(并不是)
接下来,我们主要会为 text 、image 提供一些能力拓展,并实现基本的撤销、恢复功能。能力拓展的方式是相似的,相信在看完文章后,你就可以熟练地为任意 view 类型拓展能力了。
通过刷新整个 palette 方式进行的操作
// pages/index/index.tsx
refreshPalette = (palette?: IPalette) => {
this.setState({
dancePalette: palette || { ...this.currentPalette },
});
};
最简单的刷新海报的方式就是直接刷新整个 palette 了,这种操作即便是不使用动态模版也一样可以用。这种方式开销大,速度相对慢,但是可以完全改变海报的结构
删除 View
虽然 painter 提供了自定义删除 icon 的方法,但是点击删除按钮,你会发现 view 并没有被删除。这是因为我们希望这种修改 palette 的操作能够让外部主动操作,而不是将删除操作也内置——那可能会导致你对自己写的海报模版失去掌控。要想实现删除逻辑非常简单,当用户点击删除按钮时,我们可以从 onTouchEnd 处监听到一个 type = ‘delete’ 的事件。
从 palette 中找出对应的 view 并删除,然后更新 palette 就能完成删除操作了。
// pages/index/index.tsx
this.currentPalette.views.splice(detail.index, 1);
this.refreshPalette();
修改背景
修改背景更为简单——直接改 palette 的 background 属性,然后刷新模版即可
// pages/index/index.tsx
this.currentPalette.background = color;
this.refreshPalette();
添加新 View —— 以 text 为例
准备一个预先定义好样式的 text 类型的 view ,将输入内容填充后塞入模版的 views 中,最后刷新模版即可
// common/index.ts
export function getBlankTextView(text?: string): IView {
return {
type: 'text',
text: text || '',
id: `text_${new Date().getTime()}${Math.ceil(Math.random() * 10)}`,
css: {
scalable: true,
deletable: true,
width: '384rpx',
fontSize: '36rpx',
color: '#000',
textAlign: 'center',
padding: '0 8rpx 8rpx 8rpx',
top: '50%',
left: '50%',
align: 'center',
verticalAlign: 'center',
},
};
}
// pages/index/index.tsx
this.currentPalette.views.push(getBlankTextView(inputValue));
this.refreshPalette();
通过刷新 action 方式进行的操作
// pages/index/index.tsx
refreshSelectView = (view?: IView) => {
this.setState({
action: { view: view || this.currentView },
});
};
painter 动态模版功能的一大改动就是增加了 action 属性。当我们向 action 传入一个 view ,painter 会去寻找与其匹配的 view 并刷新状态。通过这种方式,我们最小化了需要修改的内容,从而减少了 painter 所需要的渲染时间。
刷新选中view的样式——以 text 为例
通过监听 onViewClick 事件,我们能够获取当前点击的 view ,在确定当前 view 后,我们就可以通过修改改 view 的 css ,然后刷新 action 来修改样式了。具体表现如下:
// pages/index/index.tsx
this.currentView.css = newCss;
this.refreshSelectView();
除了 text ,其他各类 view 也都可以做类似操作,比如修改 rect 的尺寸、修改图片链接、基于替换图片链接实现图片裁剪等等。这里只是抛砖引玉,欢迎大家向我们分享你做出了什么炫酷的功能。
同时使用上述两种方法实现撤销与恢复操作
上面介绍了两种刷新海报的方式,而接下来,我们实现一个简单的撤销与恢复功能。这个功能的核心没有什么特殊的,就是同时维持撤销栈和恢复栈两个栈,通过记录之前所做的操作,做反向操作。
// pages/index/index.tsx
interface ITimeStackItem {
view?: IView;
palette?: IPalette;
index?: number;
type?: string;
}
pushToHistory = (item: ITimeStackItem) => {
this.future.length = 0;
while (this.history.length > 19) {
this.history.shift();
}
this.history.push(item);
this.refreshTop();
};
handleTimeMachine = (type: 'revert' | 'recover') => {
let popStack: ITimeStackItem[];
let pushStack: ITimeStackItem[];
if (type === 'revert') {
popStack = this.history;
pushStack = this.future;
} else {
pushStack = this.history;
popStack = this.future;
}
const pre = popStack.pop();
if (!pre) {
return;
}
if (pre.type === 'delete') {
this.currentView = undefined;
if (this.currentPalette.views[pre.index!] && this.currentPalette.views[pre.index!].id === pre.view!.id) {
this.currentPalette.views.splice(pre.index!, 1);
} else {
this.currentPalette.views.splice(pre.index!, 0, pre.view!);
}
pushStack.push(pre);
this.refreshPalette();
} else if (pre.palette) {
pushStack.push({
palette: JSON.parse(JSON.stringify(this.currentPalette)),
});
this.currentPalette = pre.palette;
this.currentView = undefined;
this.refreshPalette();
} else {
for (let i = 0; i < this.currentPalette.views.length; i++) {
if (this.currentPalette.views[i].id === pre.view!.id) {
pushStack.push({
view: JSON.parse(JSON.stringify(this.currentPalette.views[i])),
});
this.currentPalette.views[i] = pre.view!;
this.currentView = this.currentPalette.views[i];
this.refreshSelectView(pre.view);
break;
}
}
}
this.setState({
editState: this.currentView && this.currentView.type === 'text' ? EditState.TEXT : EditState.NORMAL,
});
this.refreshTop();
};
保存生成的海报
在操作动态模版时,是不会触发 onImgOk 的,因为动态模版的内容渲染在四个不同层级的 canvas 上,无法实时生成完善的海报图片,所以需要手动设置 palette 使用静态模版生成对应的海报
// pages/index/index.tsx
this.setState({
palette: JSON.parse(JSON.stringify(this.currentPalette)),
});
handleImgOk = path => {
...
};
总结
经过上述的一个流程,是不是对如何使用 painter 的动态模版有一些新的想法了呢?欢迎大家基于 painter 开发出更多有趣的功能并在评论区与我们分享。