绘制不规则形状的 ShapeLayer,还可以带圆角哦

如何绘制不规则形状的 ShapLayer,并且添加圆角到各个角落,完美实现拼图类的需求

2020-11-04

TOC

前言

最近在做拼图(Collage)类的需求,拼图功能简单讲就是把1~N张图片按照各种形状裁剪然后拼起来,形状有规则的也有不规则的,还可以添加各种内外边距加圆角,还可以切换到各种比例的容器里面自适配。在这篇文章着重讲一下如何最低成本实现不规则形状的裁剪以及正确添加任何大小的圆角,文末也会有封装好的 View 类和相关示例工程地址。

不规则形状裁剪

说到不规则形状裁剪实现肯定会涉及到 UIView 或者 CALayermask 属性。以 UIView 为举例的话 mask 属性需要赋值一个 UIView 类型的视图,系统会辨别赋值视图里的透明区域进行裁剪操作,实色部分和半透明区域可以得到展示。

你可以传递一个带透明度的图片视图进来进行相同形状的裁剪,你也可以绘制一个 UIBezierPath 设置到 CAShapeLayer 中生成不规则形状的 layer 进行裁剪。比如如下代码就是给一个视图添加任一角落的圆角功能实现:

let roundPath = UIBezierPath(roundedRect: roundedRect, byRoundingCorners: [.topLeft, .topRight], cornerRadii: CGSize(width: radius, height: radius))
let maskLayer = CAShapeLayer()
maskLayer.path = roundPath.cgPath
layer.mask = maskLayer

延展开来就是你可以自行绘制任何形状的 ShapeLayer,然后进行裁剪操作。但是如果每一个来自设计或者产品的形状都要自行绘制那就太麻烦了,还不一定能百分百还原,那怎么办呢?

这里就可以引入 SVG 这个图片格式了,先贴一段维基百科解释:

可缩放矢量图形(英语:Scalable Vector Graphics,缩写:SVG)是一种基于可扩展标记语言(XML),用于描述二维矢量图形的图形格式。

简单的讲用 SVG 格式描述的图片可以反向解析得到所有的图形信息,可以理解为把 SVG 图片直接解析成 iOSUIBezierPath 对象也是可行的。同时 SVG 格式目前非常容易获取,常见的设计稿网站都自带导出任何图片为 SVG 格式(比如蓝湖、慕客),把 SVG 图片解析成 UIBezierPath 也有现成的开源库叫 PocketSVG

如果只是简单的只想进行裁剪操作,那么 SVGPNG 格式都能实现,两者区别只是图片大小和绘制速度不一样( 简单形状图形上 PNG 通常会大很多,复杂形状图形上 SVG 会绘制的比较慢)。但是如果你要走到下一步,给不规则形状(三角形、N边型、斜边型、直角带波浪形等等)绘制圆角,那么肯定是只能选择 SVG 格式,同时还要做一些额外操作。

不规则形状绘制圆角

我们先重新理一下绘制不规则形状的过程:

  1. 解析 SVG 图片,得到 UIBezierPath 和绘制需要的附加属性(如 transformfill-ruleopacity 等等属性)。
  2. 使用第一步得到的信息生成一个或多个 ShapeLayer,把所有的附加信息正确赋值进去。
  3. 把最终的 ShapeLayer 添加到当前 layer 就得到了不规则形状的视图,然后按需使用。

这里我们看一下第二步大概的实现代码:

let scale = CGAffineTransform(scaleX: scaledWidthRatio, y: scaledHeightRatio)
let layerTransform = scale.concatenating(CGAffineTransform(translationX: adjustedFrame.origin.x, y: adjustedFrame.origin.y))
for i in 0..<untouchedPaths.count {
    /// 从svg图片提取的path对象,包含附加属性
    let path = untouchedPaths[i]
    /// 之前用path生成的ShapeLayer
    let layer = shapeLayers[i]

    /// 从path获取附加属性一一设置到layer
    setColor(with: path, layer: layer)
    setFillRule(with: path, layer: layer)
    setLineCap(with: path, layer: layer)
    setLineJoin(with: path, layer: layer)
    setMiterLimit(with: path, layer: layer)
    setLineDashPattern(with: path, layer: layer)

    let pathBounds = path.bounds
    layer.frame = pathBounds.applying(layerTransform)
    /// 处理layer的翻转问题
    let translationTransform = CGAffineTransform(translationX: -pathBounds.origin.x,
                                                 y: -pathBounds.origin.y)
    let pathTransform = translationTransform.concatenating(scale)
    path.apply(pathTransform)

    /// 添加圆角的方法
    let finalPath = addArcIfNeeded(path: path)
    layer.path = finalPath.cgPath
}

里面大部分都是常规操作就不细讲,这里展开一下最后的添加圆角的方法 addArcIfNeeded(path:)

给某个 UIBezierPath 添加圆角需要经过3个步骤:

  1. 解析 CGPath 部分的 pointtype 属性存储到数组里,两个分别是 CGPointCGPathElementType 类型。系统针对 CGPath 类提供了 applyWithBlock 方法进行便捷的遍历操作。
  2. 遍历此数组,辨别 CGPathElementType 类型进行 CGPathaddLineaddQuadCurveaddCurve 等操作。
  3. 进行 addLine 操作时需要判断当前点和下一个点的索引、类型等信息,决定是进行 addLine 还是 addArc 来绘制直线或者圆角。

    • 理论上连续的两个点都是直线类型才能绘制圆角。
    • 绘制到最后一个点后需要把第一个点当做它的下一个点进行圆角绘制,形成闭环。
    • 有时候首尾的点坐标都是同一个,需要过滤一下。
    • 进行 addQuadCurveaddCurve 操作时因为本身就是绘制曲线类型的,所以无法简单的进行圆角的绘制,目前只是原样绘制。
    • addArc 操作需要一个 radius,这个 radius 如果超过一定值就会出现绘制出来的曲线变形现象。如何计算得出每一个角的这个最大半径是个数学问题,在文末的示例工程里面有一个还可以接受的计算方式。

所以整个添加圆角方法的简化代码大概如下:

private func addArcIfNeeded(path: UIBezierPath) -> UIBezierPath {
    for (i, (currentPoint, type)) in pointAndTypes.enumerated() {
        if i == 0 {
            newPath.move(to: currentPoint)
        } else {
            if type == .addQuadCurveToPoint {
                newPath.addQuadCurve(to: nextPoint, control: currentPoint)
            } else if type == .addCurveToPoint {
                newPath.addCurve(to: nextNextPoint, control1: currentPoint, control2: nextPoint)
            } else {
                if allowAddArc(type: type), allowAddArc(type: nextType) {
                    addArcForRadius(path: newPath,
                                    prePoint: prePoint,
                                    tangent1End: currentPoint,
                                    tangent2End: nextPoint,
                                    radius: scaledRadius)
                } else {
                    newPath.addLine(to: currentPoint)
                }
            }

            if i == totalCount - 1 {
                if allowAddArc(type: firstType), allowAddArc(type: secondType) {
                    addArcForRadius(path: newPath,
                                    prePoint: prePoint,
                                    tangent1End: firstPoint,
                                    tangent2End: secondPoint,
                                    radius: scaledRadius)
                }
            }
        }
    }

    return UIBezierPath(cgPath: newPath)
}

总结

至此就能完整实现不规则形状的裁剪和绘制圆角功能。其中重头戏在于画圆角这一块,涉及到拆分 path 信息、重组 path 信息、计算每个角的最大 radius 、处理各种边界情况等等。

这里封装了一个视图同时整理了DEMO工程 ATSVGImageView ,想看细节实现的可以去查看对应代码 。如何计算出每个角最大圆角是个比较头疼的问题,里面虽然整理了一种计算方式,但不尽完美,如果有人知道比较好的方式欢迎留言或者提交PR。