Taro小程序canvas绘制海报(并实现自定义分享5:4海报)

823 阅读2分钟

Taro小程序canvas绘制海报(并实现自定义分享5:4海报)

接到需求开发一款电子名片小程序,前期将技术栈设为了Taro + React,在开发过程中,电子名片前期由原生标签制作,但显示效果差强人意,而且分享名片时,只能通过微信官方的分享将名片网页截取一部分,会将无关内容带到分享页中。因此,我将名片改为canvas实现,分享时将canvas转为临时图片,达到小程序的4:5比例。

最终分享效果

image.png

开发思路

  1. 接受后台返回数据,将数据显示到canvas指定位置。
  2. 分享时将canvas转为临时图片。
将数据显示到canvas上。封装配置化canvas绘制组件

lib文件夹文件可到以下链接获取。

github.com/Lsq-class/t…

import Taro from "@tarojs/taro";
import { Canvas } from "@tarojs/components";
​
import Pen from "./lib/pen";
import Downloader from "./lib/downloader";
import { getAuthSetting, saveImageToPhotosAlbum, equal } from "./lib/util";
import React from "react";
const downloader = new Downloader();
​
// 最大尝试的绘制次数
const MAX_PAINT_COUNT = 5;
​
interface IProps {
  customStyle: string; // canvas自定义样式
  palette: object; // painter模板
  widthPixels: number; // 像素宽度
  dirty: boolean; // 启用脏检查,默认 false
  onImgErr: Function; // 图片失败回调
  onImgOK: Function; // 图片成功回调
}
​
interface IState {
  painterStyle: string; // canvas 宽度+高度样式
}
​
export default class QyPoster extends React.Component<IProps, IState> {
  static defaultProps = {
    customStyle: "",
    palette: {},
    widthPixels: 0,
    dirty: false,
    onImgErr: () => null,
    onImgOK: () => null
  };
​
  canvasId: string = "k-canvas"; // canvas-id
​
  filePath: string = ''; // 生成的文件路径
​
  state: IState = {
    painterStyle: ""
  };
​
  canvasWidthInPx: number = 0; // width to px
  canvasHeightInPx: number = 0; // height to px
  paintCount: number = 0; // 绘制次数
  /**
   * 判断一个 object 是否为空
   * @param {object} object
   */
  isEmpty(object) {
    for (const _i in object) {
      return false;
    }
    return true;
  }
​
  isNeedRefresh = (newVal, oldVal) => {
    if (
      !newVal ||
      this.isEmpty(newVal) ||
      (this.props.dirty && equal(newVal, oldVal))
    ) {
      return false;
    }
    return true;
  };
​
  setStringPrototype = (screenK, scale) => {
    /**
     * 是否支持负数
     * @param {Boolean} minus 是否支持负数
     */
    //@ts-ignore
    String.prototype.toPx = function toPx(minus) {
      let reg;
      if (minus) {
        reg = /^-?[0-9]+([.]{1}[0-9]+){0,1}(rpx|px)$/g;
      } else {
        reg = /^[0-9]+([.]{1}[0-9]+){0,1}(rpx|px)$/g;
      }
      const results = reg.exec(this);
      if (!this || !results) {
        console.error(`The size: ${this} is illegal`);
        return 0;
      }
      const unit = results[2];
      const value = parseFloat(this);
​
      let res = 0;
      if (unit === "rpx") {
        res = Math.round(value * screenK * (scale || 1));
      } else if (unit === "px") {
        res = Math.round(value * (scale || 1));
      }
      return res;
    };
  };
  // 执行绘制
  startPaint = (palette) => {
    // 如果palette模板为空 则return
    if (this.isEmpty(palette)) {
      return;
    }
​
    if (!(Taro.getApp().systemInfo && Taro.getApp().systemInfo.screenWidth)) {
      try {
        Taro.getApp().systemInfo = Taro.getSystemInfoSync();
      } catch (e) {
        const error = `Painter get system info failed, ${JSON.stringify(e)}`;
        console.error(error);
        this.props.onImgErr && this.props.onImgErr(error);
        return;
      }
    }
    let screenK = Taro.getApp().systemInfo.screenWidth / 750;
    this.setStringPrototype(screenK, 1);
​
    this.downloadImages(palette).then((palette: any) => {
      const { width, height } = palette;
​
      if (!width || !height) {
        console.error(
          `You should set width and height correctly for painter, width: ${width}, height: ${height}`
        );
        return;
      }
      this.canvasWidthInPx = width.toPx();
      if (this.props.widthPixels) {
        // 如果重新设置过像素宽度,则重新设置比例
        this.setStringPrototype(
          screenK,
          this.props.widthPixels / this.canvasWidthInPx
        );
        this.canvasWidthInPx = this.props.widthPixels;
      }
​
      this.canvasHeightInPx = height.toPx();
      this.setState({
        painterStyle: `width:${this.canvasWidthInPx}px;height:${this.canvasHeightInPx}px;`
      });
      const ctx = Taro.createCanvasContext(this.canvasId, this.$scope);
      const pen = new Pen(ctx, palette);
      pen.paint(() => {
        this.saveImgToLocal();
      });
    });
  };
​
  // 下载图片
  downloadImages = (palette) => {
    return new Promise(resolve => {
      let preCount = 0;
      let completeCount = 0;
      const paletteCopy = JSON.parse(JSON.stringify(palette));
      if (paletteCopy.background) {
        preCount++;
        downloader.download(paletteCopy.background).then(
          path => {
            paletteCopy.background = path;
            completeCount++;
            if (preCount === completeCount) {
              resolve(paletteCopy);
            }
          },
          () => {
            completeCount++;
            if (preCount === completeCount) {
              resolve(paletteCopy);
            }
          }
        );
      }
      if (paletteCopy.views) {
        for (const view of paletteCopy.views) {
          if (view && view.type === "image" && view.url) {
            preCount++;
            downloader.download(view.url).then(
              path => {
                view.url = path;
                Taro.getImageInfo({
                  src: view.url,
                  //@ts-ignore
                  success: res => {
                    // 获得一下图片信息,供后续裁减使用
                    view.sWidth = res.width;
                    view.sHeight = res.height;
                  },
                  fail: error => {
                    // 如果图片坏了,则直接置空,防止坑爹的 canvas 画崩溃了
                    view.url = "";
                    console.error(
                      `getImageInfo ${view.url} failed, ${JSON.stringify(
                        error
                      )}`
                    );
                  },
                  complete: () => {
                    completeCount++;
                    if (preCount === completeCount) {
                      resolve(paletteCopy);
                    }
                  }
                });
              },
              () => {
                completeCount++;
                if (preCount === completeCount) {
                  resolve(paletteCopy);
                }
              }
            );
          }
        }
      }
      if (preCount === 0) {
        resolve(paletteCopy);
      }
    });
  };
​
  // 保存图片到本地
  saveImgToLocal = () => {
    setTimeout(() => {
      Taro.canvasToTempFilePath(
        {
          canvasId: this.canvasId,
          success: res => {
            this.getImageInfo(res.tempFilePath);
          },
          fail: error => {
            console.error(
              `canvasToTempFilePath failed, ${JSON.stringify(error)}`
            );
            this.props.onImgErr && this.props.onImgErr(error);
          }
        },
        this.$scope
      );
    }, 300);
  };
​
  getImageInfo = filePath => {
    Taro.getImageInfo({
      src: filePath,
      //@ts-ignore
      success: infoRes => {
        if (this.paintCount > MAX_PAINT_COUNT) {
          const error = `The result is always fault, even we tried ${MAX_PAINT_COUNT} times`;
          console.error(error);
          this.props.onImgErr && this.props.onImgErr(error);
          return;
        }
        // 比例相符时才证明绘制成功,否则进行强制重绘制
        if (
          Math.abs(
            (infoRes.width * this.canvasHeightInPx -
              this.canvasWidthInPx * infoRes.height) /
            (infoRes.height * this.canvasHeightInPx)
          ) < 0.01
        ) {
          this.filePath = filePath;
          this.props.onImgOK && this.props.onImgOK({ path: filePath });
        } else {
          this.startPaint(this.props.palette);
        }
        this.paintCount++;
      },
      fail: error => {
        console.error(`getImageInfo failed, ${JSON.stringify(error)}`);
        this.props.onImgErr && this.props.onImgErr(error);
      }
    });
  };
​
  // 保存海报到手机相册
  saveImage() {
    const scope = "scope.writePhotosAlbum";
    getAuthSetting(scope).then((res: boolean) => {
      if (res) {
        // 授权过 直接保存
        this.saveImageToPhotos();
        return false;
      }
      // 未授权过 先获取权限
      getAuthSetting(scope).then((status: boolean) => {
        if (status) {
          // 获取保存图片到相册权限成功
          this.saveImageToPhotos();
          return false;
        }
        // 用户拒绝授权后的回调 获取权限失败
        Taro.showModal({
          title: "提示",
          content: "若不打开授权,则无法将图片保存在相册中!",
          showCancel: true,
          cancelText: "暂不授权",
          cancelColor: "#000000",
          confirmText: "去授权",
          confirmColor: "#3CC51F",
          success: function (res) {
            if (res.confirm) {
              // 用户点击去授权
              Taro.openSetting({
                //调起客户端小程序设置界面,返回用户设置的操作结果。
              });
            } else {
              //
            }
          }
        });
      });
    });
  }
  getImportUrl() {
    return this.filePath
  }
  saveImageToPhotos = () => {
    saveImageToPhotosAlbum(this.filePath)
      .then(() => {
        // 成功保存图片到本地相册
        // 保存失败
        Taro.showToast({
          title: "保存成功",
          icon: "none"
        });
      })
      .catch(() => {
        // 保存失败
        Taro.showToast({
          title: "保存失败",
          icon: "none"
        });
      });
  };
​
  componentWillMount() {
    this.startPaint(this.props.palette);
  }
  componentWillReceiveProps(nextProps) {
    if (nextProps.palette !== this.props.palette) {
      this.paintCount = 0;
      this.startPaint(nextProps.palette);
    }
  }
​
  render() {
    return (
        <Canvas
          canvasId={this.canvasId}
          style={`${this.state.painterStyle}${this.props.customStyle}`}
        />
    );
  }
}
​
使用组件绘制名片
import Taro, { Config } from "@tarojs/taro";
import { View, Button } from "@tarojs/components";
​
import Card from "./card";
import Poster from "../poster";
import "./index.scss";
// eslint-disable-next-line import/first
import { Component } from "react";
// eslint-disable-next-line import/first
import { deepEqual } from "~/servers/methods/utils";
// 名片内字段定义
export interface CardIProps {
    avatarUrl,
    name,
    jobTitle,
    company,
    location,
    email,
    phone,
    BackgroundUrl,
    background,
    isVisibel?: boolean,
}
interface IState {
    imagePath: string;
    template: object;
}
​
export default class Index extends Component<CardIProps, IState> {
    /**
     * 指定config的类型声明为: Taro.Config
     *
     * 由于 typescript 对于 object 类型推导只能推出 Key 的基本类型
     * 对于像 navigationBarTextStyle: 'black' 这样的推导出的类型是 string
     * 提示和声明 navigationBarTextStyle: 'black' | 'white' 类型冲突, 需要显示声明类型
     */
    config: Config = {
        navigationBarTitleText: "painter"
    };
    state: IState = {
        template: new Card().palette(),
        // eslint-disable-next-line react/no-unused-state
        imagePath: "",
    };
    // 图片生成成功回调
    onImgOK = e => {
        this.setState({
            // eslint-disable-next-line react/no-unused-state
            imagePath: e.path
        });
    };
    // 图片生成失败回调
    onImgErr = error => {
        console.log(
            "%cerror: ",
            "color: MidnightBlue; background: Aquamarine; font-size: 20px;",
            error
        );
    };
    public painterRef: Poster | null;
​
    // 保存图片到本地相册
    getImageUrl() {
        if (this.painterRef) {
            return this.painterRef.getImportUrl()
        }
    }
​
    componentWillReceiveProps(nextProps: Readonly<CardIProps>, nextContext: any): void {
        if (!deepEqual(nextProps, this.props)) {
            this.setState({ template: new Card().palette({ ...nextProps }) })
        }
    }
​
​
    render() {
        const { isVisibel } = this.props
        return (
            <View className="index">
                <Poster
                  customStyle={isVisibel ? "display: none;" : ""}
                  palette={this.state.template}
                  onImgOK={this.onImgOK}
                  onImgErr={this.onImgErr}
                  ref={node => {
                        this.painterRef = node
                    }}
                  dirty={false}
                />
            </View>
        );
    }
}
​
canvas配置文件

由于需要不同背景,canvas展示位置不同,因此这里对样式做了特殊判断

import { CardIProps } from "./ExportImg"export default class LastMayday {
  palette(cardProps?: CardIProps) {
    const isBlueBack = cardProps?.background === 1 || cardProps?.background === 4
    const isOneStyle = cardProps?.background === 3 || cardProps?.background === 5 || cardProps?.background === 1 || cardProps?.background === 4
    const fontColor = {
      color: isBlueBack ? '#FFFFFF' : undefined
    };   
    const infoFontColor = {
      color: isBlueBack ? '#d4eafe' : undefined
    };
    const infoPosition = {
      left: isOneStyle ? "40rpx" : '220rpx'
    }
    const avatarPosition = {
      left: isOneStyle ? "" : '40rpx',
      right: isOneStyle && '40rpx'
    }
    const textPositon = {
      left: isOneStyle ? "82rpx" : '262rpx'
  }
    return (
      {
        "width": "702rpx",
        "height": "484rpx",
        "background": "链接地址",
        borderRadius: '24rpx',
        border: "4rpx solid #F4F4F4",
        backgroundSize: 'cover',
        "views": [
          {
            type: 'text',
            text: cardProps?.company ? cardProps?.company : "",
            css: [{
              top: `44rpx`,
              fontWeight: 'bold',
              color: '#262832',
              fontSize: '32rpx',
              left: '40rpx'
            }, fontColor],
          },
          {
            "type": "image",
            "url": cardProps?.avatarUrl ? cardProps?.avatarUrl : "",
            "css": [{
              "width": "140rpx",
              "height": "140rpx",
              "top": "110rpx",
              "rotate": "0",
              "borderRadius": "70rpx",
              "borderWidth": "",
              "borderColor": "#000000",
              "shadow": "",
              "mode": "aspectFill"
            }, avatarPosition]
          },
          {
            type: 'text',
            text: cardProps?.name ? cardProps?.name : "",
            css: [{
              top: `121rpx`,
              fontWeight: 'bold',
              fontSize: '44rpx',
              color: '#262832'
            }, fontColor, infoPosition],
          }, {
            type: 'text',
            text: cardProps?.jobTitle ? cardProps?.jobTitle : "",
            css: [{
              top: `195rpx`,
              // fontWeight: 'bold',
              fontSize: '30rpx',
              color: '#262832'
            }, fontColor, infoPosition],
          },
          {
            "type": "image",
            "url": "链接地址",
            "css": [{
              "width": "24rpx",
              "height": "24rpx",
              "top": "291rpx",
              "rotate": "0",
              "borderWidth": "",
              "borderColor": "#000000",
              "shadow": "",
              "mode": "aspectFill"
            }, infoPosition]
          },
          {
            type: 'text',
            text: cardProps?.phone ? cardProps?.phone : "",
            css: [{
              top: `282rpx`,
              // left: '242rpx',
              fontSize: '28rpx',
              color: '#17171A'
            }, infoFontColor, textPositon],
          }, {
            "type": "image",
            "url": "链接地址",
            "css": [{
              "width": "24rpx",
              "height": "24rpx",
              "top": "339rpx",
              "rotate": "0",
              "borderWidth": "",
              "borderColor": "#000000",
              "shadow": "",
              "mode": "aspectFill"
            }, infoPosition]
          },
          {
            type: 'text',
            text: cardProps?.email ? cardProps?.email : "",
            css: [{
              top: `330rpx`,
              // left: '242rpx',
              fontSize: '28rpx',
              color: '#17171A'
            }, infoFontColor, textPositon],
          }, {
            "type": "image",
            "url": "链接地址",
            "css": [{
              "width": "24rpx",
              "height": "24rpx",
              "top": "387rpx",
              "rotate": "0",
              "borderWidth": "",
              "borderColor": "#000000",
              "shadow": "",
              "mode": "aspectFill"
            }, infoPosition]
          },
          {
            type: 'text',
            text: cardProps?.location ? cardProps?.location : "",
            css: [{
              top: `378rpx`,
              // left: '242rpx',
              fontSize: '28rpx',
              lineHeight: '38rpx',
              color: '#17171A',
              width: isOneStyle ? "580rpx" :'420rpx'
            }, infoFontColor, textPositon],
          },
        ]
      }
    );
  }
}
canvas绘图组件传入格式说明
{
        "width": "702rpx",  //canvas宽度
        "height": "484rpx", // canvas高度
        "background": "链接地址", // 背景图
        borderRadius: '24rpx', // 边框圆角
        border: "4rpx solid #F4F4F4", // 边框
        backgroundSize: 'cover', // 背景设置覆盖方式
        "views": [  // 配置canvas内子元素
          {
            type: 'text', // 文字类型
            text: "lsq_137", // 文字内容
            css: [{    // 给文字设置样式(支持多对象插入)
              top: `44rpx`,
              fontWeight: 'bold',
              color: '#262832',
              fontSize: '32rpx',
              left: '40rpx'
            }],
          },
          {
            "type": "image", // 图片类型
            "url": "图片链接地址", // 链接地址
            "css": [{ // 样式(支持多对象插入)
              "width": "140rpx",
              "height": "140rpx",
              "top": "110rpx",
              "rotate": "0",
              "borderRadius": "70rpx",
              "borderWidth": "",
              "borderColor": "#000000",
              "shadow": "",
              "mode": "aspectFill"
            }, {color: "#fff"}
            ]
          }
        ]
小程序分享以图片形式分享
import { useEffect, useRef, useState } from "react";
import { View, Image, Canvas } from "@tarojs/components";
import { Icon } from "@antmjs/vantui";
import Taro, {
  useShareAppMessage,
} from "@tarojs/taro";
​
​
function Index() {
  const [cardInfo, cardInfoSet] = useState<any>();
  let painterRef: any = useRef(null)
​
  const onInit = async () => {
  };
  /**
 * 生成分享5:4的图片
 */
  const makeCanvas = (imgUrl) => {
    return new Promise((resolve, reject) => {
      // 获取图片信息,小程序下获取网络图片信息需先配置download域名白名单才能生效
      const sysInfo = Taro.getSystemInfoSync()
      Taro.getImageInfo({
        src: imgUrl,
        success: (imgInfo) => {
          // 获取设备像素比 适配图片
          const pixelRatio = sysInfo.pixelRatio
          let ctx = Taro.createCanvasContext('canvas')
          let canvasW = imgInfo.width / pixelRatio
          let canvasH = ((imgInfo.width * 4) / 5) / pixelRatio
          // 把比例设置为 宽比高 5:4
          // canvasW = (imgInfo.height * 5) / 4
          // 为画框设置背景色,注意要放在画图前,图会覆盖在背景色上
          ctx.fillStyle = "#fff";
          ctx.fillRect(0, 0, canvasW, canvasH)
          ctx.drawImage(
            imgInfo.path,
            0,
            (canvasH - imgInfo.height / pixelRatio) / 2,
            canvasW,
            imgInfo.height / pixelRatio
          )
          // }
​
          ctx.draw(false, () => {
            setTimeout(() => {
              Taro.canvasToTempFilePath({
                width: canvasW,
                height: canvasH,
                // destWidth: 750, // 标准的iphone6尺寸的两倍,生成高清图
                // destHeight: 600,
                canvasId: "canvas",
                fileType: "png", // 注意jpg默认背景为透明
                success: (res) => {
                  resolve(res.tempFilePath)
                },
                fail: (err) => {
                  reject(err)
                }
              })
            }, 0)
          })
        },
        fail: (err) => {
          reject(err)
        }
      })
    })
  }
  useShareAppMessage((res) => {
    if (res.from === "button") {
      // 来自页面内转发按钮
      console.log(res.target);
    }
    let imgUrl = ""
    if (painterRef) {
      imgUrl = painterRef?.getImageUrl()
    }
    return new Promise((resolve, reject) => {
      return makeCanvas(imgUrl).then(imgPath => {
        resolve({
          title: "请保存!",
          path: `/pages/mineHome/index?open_code=${cardInfo?.open_code}&visit_from=1`,
          imageUrl: imgPath
        })
      }).catch(err => {
        resolve({
          title: "请保存!",
          path: `/pages/mineHome/index?open_code=${cardInfo?.open_code}&visit_from=1`,
          // imageUrl: '处理失败后展示的图片,可以用原图shareMessage.imageUrl'
        })
      })
    })
  });
  const cardInfoProps = {
    name: cardInfo?.name,
    company: cardInfo?.company,
    jobTitle: cardInfo?.job?.[0],
    phone: cardInfo?.cellphone,
    email: cardInfo?.email?.[0],
    location: cardInfo?.address,
    avatarUrl:
      cardInfo?.avatar,
    BackgroundUrl:
      cardInfo?.background_picture,
    background: cardInfo?.background
  }
  // 获取当前设备屏幕宽度
  const sysInfo = Taro.getSystemInfoSync()
  const canvasHeight = (sysInfo?.screenWidth * 4) / 5
​
  return (
    <View className="page">
      <View id="export-element">
        <ExportImg ref={node => {
          painterRef = node
        }}
          isVisibel={isVisit}
          {...cardInfoProps}
        />
      </View>
      {* 导出图片使用到的canvas *}
       <Canvas
        canvasId={"canvas"}
        style={`position: absolute; top: -1000px; left: -1000px; width: ${sysInfo?.screenWidth}px; height: ${canvasHeight}px;`}
      />
    </View>
  );
}
​
export default Index;

纯js开发微信小游戏外挂(开局托儿所)