Flutter实现优惠券样式

3,065 阅读4分钟

最近做了一个小需求,卡片样式是这样的:

按照我常规的做法,一般都是切个图,然后就实现了(可能会变形),是不是感觉很low,我就从来没想过用代码实现这个。

前两天看了这篇文章: juejin.im/post/684490… 把我给惊讶到了,咦,原来优惠券界面可以手写呀,这次碰到这个需求,正好试着搞一把。原文中用的是圆形,我这是锯齿形,这个应该怎么做呢?下面我细细讲解下,相信能学习到一些知识,重点不在代码,而在实现过程:

  1. 实现两边的扣圆:

按照上面文章的说法,这个好实现,首先原始图形代码是这样的:

蓝色块:

Material(
    shape: CouponShapeBorder(),
    color: Colors.blue,
    child: Container(
    width: 100,
    height: 100,
    ),
)

CouponShapeBorder:

import 'dart:math';

import 'package:flutter/material.dart';

class CouponShapeBorder extends ShapeBorder {
  final double circleSize;// 圆形直径
  final double topMargin;// 圆形距上距离

  CouponShapeBorder({
    this.circleSize = 12,
    this.topMargin = 65,
  });

  @override
  EdgeInsetsGeometry get dimensions => null;

  @override
  Path getInnerPath(Rect rect, {TextDirection textDirection}) {
    return null;
  }

  // 获取外轮廓Path
  @override
  Path getOuterPath(Rect rect, {TextDirection textDirection}) {
    var path = Path();
    path.addRect(rect);

    _formHoldLeft(path, rect);
    _formHoldRight(path, rect);
    path.fillType = PathFillType.evenOdd;
    return path;
  }
  _formHoldLeft(Path path, Rect rect) {
    path.addArc(
        Rect.fromCenter(
          center: Offset(0, topMargin),
          width: circleSize,
          height: circleSize,
        ),
        pi * 3 / 2,
        pi);
  }

  _formHoldRight(Path path, Rect rect) {
    path.addArc(
        Rect.fromCenter(
          center: Offset(rect.width, topMargin),
          width: circleSize,
          height: circleSize,
        ),
        pi / 2,
        pi);
  }

  @override
  void paint(Canvas canvas, Rect rect, {TextDirection textDirection}) {
  }

  @override
  ShapeBorder scale(double t) {
    throw UnimplementedError();
  }

}

整个代码中,核心的是:getOuterPath()这个方法,从名字上我们可以看出是用来处理外轮廓的,并返回path,再就是用到了path.fillType = PathFillType.evenOdd;,这个作用是,当绘画的时候,绘制奇数遍的时候,是可见的,偶数的时候是不可见的,及异或画笔,这个在很多语言中都有。

说到这里,做过绘制的同学,就知道了,我们只要返回一个圆去擦除就好,但如果只是写一个圆,会出现这种情况:

哇哦,长出了两个耳朵!

所以我们应该不能画正圆,而应该画半圆: 使用Path的这个方法:

addArc(Rect oval, double startAngle, double sweepAngle)

第一个参数:圆的大小位置 第二个参数:起始角度 第三个参数:圆弧扫过的度数

这里重点说一下第二、第三两个参数是怎么结合的?看下图示:

根据图来画弧形就好,找好起始点和要扫过的度数。

例如demo里面,左面半弧形,就应该是 起始:pi*3/2, 扫半圆也就是:pi,因此我们的代码是:

path.addArc(
    Rect.fromCenter(
      center: Offset(0, topMargin),
      width: circleSize,
      height: circleSize,
    ),
    pi * 3 / 2,
pi);

同理,右半弧就是起始:pi/2,扫:pi,假如有上半弧,就是起始0,扫:pi,有兴趣的可以上下左右都试一下~~

  1. 画分割线: 划分割线,是在paint方法中进行绘制: 这个比较简单,我们使用canvas.drawLine,根据宽度、短线长度、短线间隙进行循环画即可:
@override
  void paint(Canvas canvas, Rect rect, {TextDirection textDirection}) {
    var paint = Paint()
      ..color = dashColor
      ..strokeWidth = 1
      ..style = PaintingStyle.stroke
      ..strokeJoin = StrokeJoin.round;

    _drawDashLine(canvas, Offset(circleSize / 2 + dashLinePadding, topMargin),
        Offset(rect.width - circleSize / 2 - dashLinePadding, topMargin), paint);
  }

  _drawDashLine(Canvas canvas, Offset start, Offset end, Paint paint) {
    double currentLength = start.dx;
    // 循环去画即可
    while (currentLength <= end.dx) {
      Offset lineStart = Offset(currentLength, start.dy);
      Offset lineEnd = Offset(currentLength + dashWidth, start.dy);
      canvas.drawLine(lineStart, lineEnd, paint);
      currentLength += dashGap;
    }
  }

  1. 接下来我们重点讲一下下面的锯齿怎么画呢?

我们先画个简单的(holeCount=4)个锯齿的优惠券,如图:

按照上面扣圆的方式,我们这里扣三角形,我画了个示意图:

每个红框就是我们要扣掉的三角形。要扣三角形,我们需要一些参数即三角形的三个顶点坐标。

从这个方法中:Path getOuterPath(Rect rect, {TextDirection textDirection}) {

我们可以拿到这个优惠券矩形的长宽、位置。

再来几个前提条件: 假如设计给我们的设计稿,上面有(holeCount=4)个锯齿,每个锯齿的深度是5,即三角形的高为5。 从图片我们可以看出,(holeCount=4)个锯齿,我们需要用(holeCount+1)个三角形去擦除(多出一个为两边半个三角形之和)。

var w = rect.width;//优惠券整个宽度
//计算每个三角形的宽度,此宽度跟要擦除的三角形一样大
var polygonWidth = w / (holeCount);

根据图示,我们开始画三角形,先看下代码:

void _formHoleBottom(Path path, Rect rect, double polygonWidth) {
    final w = rect.width;  // 优惠券的宽度
    final h = rect.height; // 优惠券的高度
    for (int i = 0; i <= holeCount; i++) {
    Offset top = Offset(i * polygonWidth, h - 5);
    Offset bottomLeft = Offset(max(i * polygonWidth - polygonWidth / 2, 0), h);
    Offset bottomRight = Offset(min(i * polygonWidth + polygonWidth / 2, w), h);
    path.addPolygon([top, bottomLeft, bottomRight], true);
}

我们讲解一下: 我们总共需要画holeCount+1个三角形,那我们就直接从0到holeCount循环即可。

a. 三角形的顶点:

x坐标为每个三角形的中心,分别是:0,polygonWidth, polygonWidth * 2 ... polygonWidth * n,

y坐标:为优惠券高度-预设的5 即:(h-5)

b. 三角形的左脚坐标:

x坐标:0,polygonWidth - polygonWidth/2, 2 * polygonWidth - polygonWidth/2 ... n * polygonWidth - polygonWidth/2,

总结后是:max(i * polygonWidth - polygonWidth / 2, 0)

为什么用max,原因是第一个三角形,如果补全,其实是-polygonWidth/2,但我们不能越界,因此只需要跟0取最大值即可。

y坐标都是优惠券高度h,就不说什么啦

c. 三角形右脚坐标:

x坐标:将左脚坐标每个值加上polygonWidth, 规律即是:i * polygonWidth + polygonWidth / 2

同样的考虑到越界问题,我们对优惠券总宽度取最小值,即 min(i * polygonWidth + polygonWidth / 2, w)

y坐标也是优惠券高度h

有了三角形的三个点坐标,我们就可以调用画多边形的方法啦:

path.addPolygon([top, bottomLeft, bottomRight], true);//闭合曲线

至此我们的优惠券就完成啦,思路是重要的,以后遇到其他形状也可以类推解决了。最后附一下整体代码,大家可以改改参数试试:

import 'dart:math';

import 'package:flutter/material.dart';

class CouponShapeBorder extends ShapeBorder {
  final double circleSize;
  final int holeCount;
  final double topMargin;
  final Color dashColor;
  final double dashWidth = 3;
  final double dashGap = 7;
  final double dashLinePadding = 6;

  CouponShapeBorder({
    this.holeCount = 4,
    this.circleSize = 12,
    this.topMargin = 65,
    this.dashColor = const Color(0xffaaaaaa),
  });

  @override
  EdgeInsetsGeometry get dimensions => null;

  @override
  Path getInnerPath(Rect rect, {TextDirection textDirection}) {
    return null;
  }

  @override
  Path getOuterPath(Rect rect, {TextDirection textDirection}) {
    var w = rect.width;
    var polygonWidth = w / (holeCount);

    var path = Path();
    path.addRect(rect);

    _formHoldLeft(path, rect);
    _formHoldRight(path, rect);
    _formHoleBottom(path, rect, polygonWidth);
    path.fillType = PathFillType.evenOdd;
    return path;
  }

  void _formHoleBottom(Path path, Rect rect, double polygonWidth) {
    final w = rect.width;
    final h = rect.height;
    for (int i = 0; i <= holeCount; i++) {
      Offset top = Offset(i * polygonWidth, h - 5);
      Offset bottomLeft = Offset(max(i * polygonWidth - polygonWidth / 2, 0), h);
      Offset bottomRight = Offset(min(i * polygonWidth + polygonWidth / 2, w), h);
      path.addPolygon([top, bottomLeft, bottomRight], true);
    }
  }

  _formHoldLeft(Path path, Rect rect) {
    path.addArc(
        Rect.fromCenter(
          center: Offset(0, topMargin),
          width: circleSize,
          height: circleSize,
        ),
        pi * 3 / 2,
        pi);
  }

  _formHoldRight(Path path, Rect rect) {
    path.addArc(
        Rect.fromCenter(
          center: Offset(rect.width, topMargin),
          width: circleSize,
          height: circleSize,
        ),
        pi / 2,
        pi);
  }

  @override
  void paint(Canvas canvas, Rect rect, {TextDirection textDirection}) {
    var paint = Paint()
      ..color = dashColor
      ..strokeWidth = 1
      ..style = PaintingStyle.stroke
      ..strokeJoin = StrokeJoin.round;

    _drawDashLine(canvas, Offset(circleSize / 2 + dashLinePadding, topMargin),
        Offset(rect.width - circleSize / 2 - dashLinePadding, topMargin), paint);
  }

  _drawDashLine(Canvas canvas, Offset start, Offset end, Paint paint) {
    double currentLength = start.dx;
    while (currentLength <= end.dx) {
      Offset lineStart = Offset(currentLength, start.dy);
      Offset lineEnd = Offset(currentLength + dashWidth, start.dy);
      canvas.drawLine(lineStart, lineEnd, paint);
      currentLength += dashGap;
    }
  }

  @override
  ShapeBorder scale(double t) {
    throw UnimplementedError();
  }
}