是否可以将 UILabel 或 CATextLayer 添加到 Swift 中的 CGPath,类似于 Photoshop 的类型到路径功能?

Joh*_*rtz 1 calayer uilabel cgpath catextlayer swift

我想添加文本,无论是 aUILabel还是CATextLayera CGPath。我意识到此功能背后的数学相当复杂,但想知道 Apple 是否提供了开箱即用的此功能,或者是否有开源 SDK 可以在 Swift 中实现这一点。谢谢!

例子:

Rob*_*ier 5

您需要手动执行此操作,方法是在您关心的每个点计算贝塞尔函数及其斜率,然后在该点绘制字形并进行旋转。您需要知道 4 个点(传统上称为 P0-P3)。P0 是曲线的起点。P1和P2是控制点。P3 是曲线的终点。

Bezier 函数被定义为当“t”参数从 0 移动到 1 时,输出将跟踪所需的曲线。重要的是要知道“t”不是线性的。t=0.25 并不一定意味着“沿着曲线的 1/4”。(事实上​​,这几乎从来都不是真的。)这意味着测量长曲线的距离有点棘手。但我们会介绍这一点。

首先,您需要 CGPoint 的核心功能和有用的扩展:

// The Bezier function at t
func bezier(_ t: CGFloat, _ P0: CGFloat, _ P1: CGFloat, _ P2: CGFloat, _ P3: CGFloat) -> CGFloat {
           (1-t)*(1-t)*(1-t)         * P0
     + 3 *       (1-t)*(1-t) *     t * P1
     + 3 *             (1-t) *   t*t * P2
     +                         t*t*t * P3
}

// The slope of the Bezier function at t
func bezierPrime(_ t: CGFloat, _ P0: CGFloat, _ P1: CGFloat, _ P2: CGFloat, _ P3: CGFloat) -> CGFloat {
       0
    -  3 * (1-t)*(1-t) * P0
    + (3 * (1-t)*(1-t) * P1) - (6 * t * (1-t) * P1)
    - (3 *         t*t * P2) + (6 * t * (1-t) * P2)
    +  3 * t*t * P3
}

extension CGPoint {
    func distance(to other: CGPoint) -> CGFloat {
        let dx = x - other.x
        let dy = y - other.y
        return hypot(dx, dy)
    }
}
Run Code Online (Sandbox Code Playgroud)

t*t*t比使用该pow函数快得多,这就是代码以这种方式编写的原因。这些函数将被大量调用,因此它们需要相当快。

然后是视图本身:

class PathTextView: UIView { ... }
Run Code Online (Sandbox Code Playgroud)

首先它包括控制点和文本:

var P0 = CGPoint.zero
var P1 = CGPoint.zero
var P2 = CGPoint.zero
var P3 = CGPoint.zero

var text: NSAttributedString {
    get { textStorage }
    set {
        textStorage.setAttributedString(newValue)
        locations = (0..<layoutManager.numberOfGlyphs).map { [layoutManager] glyphIndex in
            layoutManager.location(forGlyphAt: glyphIndex)
        }

        lineFragmentOrigin = layoutManager
            .lineFragmentRect(forGlyphAt: 0, effectiveRange: nil)
            .origin
    }
}
Run Code Online (Sandbox Code Playgroud)

每次更改文本时,layoutManager 都会重新计算所有字形的位置。我们稍后将调整这些值以适应曲线,但这些是基线。位置是每个字形相对于片段原点的位置,这也是我们需要跟踪的原因。

一些零碎的事情:

private let layoutManager = NSLayoutManager()
private let textStorage = NSTextStorage()

private var locations: [CGPoint] = []
private var lineFragmentOrigin = CGPoint.zero

init() {
    textStorage.addLayoutManager(layoutManager)
    super.init(frame: .zero)
    backgroundColor = .clear
}

required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
Run Code Online (Sandbox Code Playgroud)

Bezier 函数实际上是一个一维函数。为了在二维中使用它,我们调用它两次,一次用于 x,一次用于 y,类似地计算每个点的旋转。

func getPoint(forOffset t: CGFloat) -> CGPoint {
    CGPoint(x: bezier(t, P0.x, P1.x, P2.x, P3.x),
            y: bezier(t, P0.y, P1.y, P2.y, P3.y))
}

func getAngle(forOffset t: CGFloat) -> CGFloat {
    let dx = bezierPrime(t, P0.x, P1.x, P2.x, P3.x)
    let dy = bezierPrime(t, P0.y, P1.y, P2.y, P3.y)
    return atan2(dy, dx)
}
Run Code Online (Sandbox Code Playgroud)

最后一件内务,是时候深入了解真正的功能了。我们需要一种方法来计算我们必须改变多少“t”(偏移量)才能沿着路径移动一定的距离。我不相信有任何简单的方法来计算这个,所以我们迭代来近似它。

// Simplistic routine to find the offset along Bezier that is
// aDistance away from aPoint. anOffset is the offset used to
// generate aPoint, and saves us the trouble of recalculating it
// This routine just walks forward until it finds a point at least
// aDistance away. Good optimizations here would reduce the number
// of guesses, but this is tricky since if we go too far out, the
// curve might loop back on leading to incorrect results. Tuning
// kStep is good start.
func getOffset(atDistance distance: CGFloat, from point: CGPoint, offset: CGFloat) -> CGFloat {
    let kStep: CGFloat = 0.001 // 0.0001 - 0.001 work well
    var newDistance: CGFloat = 0
    var newOffset = offset + kStep
    while newDistance <= distance && newOffset < 1.0 {
        newOffset += kStep
        newDistance = point.distance(to: getPoint(forOffset: newOffset))
    }
    return newOffset
}
Run Code Online (Sandbox Code Playgroud)

好,终于!是时候画点东西了。

override func draw(_ rect: CGRect) {

    let context = UIGraphicsGetCurrentContext()!

    var offset: CGFloat = 0.0
    var lastGlyphPoint = P0
    var lastX: CGFloat = 0.0

    // Compute location for each glyph, transform the context, and then draw
    for (index, location) in locations.enumerated() {
        context.saveGState()

        let distance = location.x - lastX
        offset = getOffset(atDistance: distance, from: lastGlyphPoint, offset: offset)

        let glyphPoint = getPoint(forOffset: offset)
        let angle = getAngle(forOffset: offset)

        lastGlyphPoint = glyphPoint
        lastX = location.x

        context.translateBy(x: glyphPoint.x, y: glyphPoint.y)
        context.rotate(by: angle)

        // The "at:" in drawGlyphs is the origin of the line fragment. We've already adjusted the
        // context, so take that back out.
        let adjustedOrigin = CGPoint(x: -(lineFragmentOrigin.x + location.x),
                                     y: -(lineFragmentOrigin.y + location.y))

        layoutManager.drawGlyphs(forGlyphRange: NSRange(location: index, length: 1),
                                 at: adjustedOrigin)

        context.restoreGState()
    }
}
Run Code Online (Sandbox Code Playgroud)

有了它,您可以沿着任何三次贝塞尔曲线绘制文本。

CurvyText 的图像

这不处理任意 CGPaths。它明确用于三次贝塞尔。调整它以沿着任何其他类型的路径(四边形曲线、圆弧、直线,甚至圆角矩形)工作非常简单。然而,处理多元素路径会带来更多的复杂性。

有关使用 SwiftUI 的完整示例,请参阅CurvyText