Swiftで文字列の輪郭に線をつける（UIBezierPath版）

## 概要
[Swiftで文字列の輪郭に線をつける](http://www.macneko.com/entry/apply-outline-to-uilabel) という記事を書いたんだけど、仕上がりに納得がいかなかったので別のアプローチで実現してみた。
[ブログから転記](http://www.macneko.com/entry/apply-outline-to-string)  

## ゴール
Illustratorのアピアランス機能で作って画像化したものと近い感じにする。
![illustrator](https://user-images.githubusercontent.com/5406126/53022875-73472e80-349f-11e9-8530-48aaafac91d4.png)

## 問題点
* `NSAttributedString` の `attributes` で直接線をつけていたため、線の太さに応じて塗りの範囲が狭くなってしまった
* 文字が角ばり、丸くできない

## 実現方法
文字を `UIBezierPath` に変換して描画するようにした。
実装は [【iOS】テキストの輪郭パスを取得する](https://www.itcowork.co.jp/blog/?p=1995) を参考にさせていただき、Swiftに書き直した。
あわせて `TextOutlineShapeView` というカスタムViewを作って、その中で描画するようにした。
構造としては文字列を  `UIBezierPath` に変換して、そのPathをセットした `CAShapeLayer` を3つ重ねて、それぞれ「塗り」「線」「影」の設定を適用した。

`TextOutlineShapeView` の実装は以下の通り。

```swift
import UIKit

class TextOutlineShapeView: UIView {
    struct TextOptions {
        let text: String
        let font: UIFont
        let lineSpacing: CGFloat
        let textAlignment: NSTextAlignment
    }

    struct ShapeOptions {
        let lineJoin: CAShapeLayerLineJoin
        let fillColor: CGColor
        let strokeColor: CGColor
        let lineWidth: CGFloat
        let shadowColor: CGColor
        let shadowOffset: CGSize
        let shadowRadius: CGFloat
        let shadowOpacity: Float
    }

    private var textOptions: TextOptions?
    private var shapeOptions: ShapeOptions?

    init(textOptions: TextOptions, shapeOptions: ShapeOptions) {
        self.textOptions = textOptions
        self.shapeOptions = shapeOptions
        super.init(frame: .zero)
        configure()
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }
}

extension TextOutlineShapeView {
    private func configure() {
        guard let textOptions = self.textOptions,
            let shapeOptions = self.shapeOptions else {
                return
        }
        let textArray = textOptions.text.components(separatedBy: "\n")
        let attibutes: [NSAttributedString.Key: Any] = [.font: textOptions.font]
        let attibutedStrings = textArray.map { text -> NSAttributedString in
            return NSAttributedString(string: text, attributes: attibutes)
        }
        guard let bezier = makePathfromText(attibutedStrings: attibutedStrings, lineSpacing: textOptions.lineSpacing, textAlignment: textOptions.textAlignment) else { return }
        bezier.flip(direction: .y)

        let shadowLayer = CAShapeLayer()
        shadowLayer.lineJoin = shapeOptions.lineJoin
        shadowLayer.frame = self.bounds
        shadowLayer.shadowPath = bezier.cgPath
        shadowLayer.shadowColor = shapeOptions.shadowColor
        shadowLayer.shadowOffset = shapeOptions.shadowOffset
        shadowLayer.shadowRadius = shapeOptions.shadowRadius
        shadowLayer.shadowOpacity = shapeOptions.shadowOpacity
        self.layer.addSublayer(shadowLayer)

        let strokeLayer = CAShapeLayer()
        strokeLayer.lineJoin = shapeOptions.lineJoin
        strokeLayer.frame = self.bounds
        strokeLayer.strokeColor = shapeOptions.strokeColor
        strokeLayer.lineWidth = shapeOptions.lineWidth
        strokeLayer.path = bezier.cgPath
        self.layer.addSublayer(strokeLayer)

        let fillLayer = CAShapeLayer()
        fillLayer.lineJoin = shapeOptions.lineJoin
        fillLayer.frame = self.bounds
        fillLayer.fillColor = shapeOptions.fillColor
        fillLayer.path = bezier.cgPath
        self.layer.addSublayer(fillLayer)
    }

    private func makePathfromText(attibutedStrings: [NSAttributedString], lineSpacing: CGFloat, textAlignment: NSTextAlignment) -> UIBezierPath? {
        let path = UIBezierPath()
        path.move(to: .zero)

        var maxWidth: CGFloat = 0
        for attibutedString in attibutedStrings {
            let line = CTLineCreateWithAttributedString(attibutedString)
            let rect = CTLineGetBoundsWithOptions(line, .useGlyphPathBounds)
            let width = rect.width

            if maxWidth < width {
                maxWidth = width
            }
        }

        for (i, attibutedString) in attibutedStrings.reversed().enumerated() {
            let letters = CGMutablePath()

            let line = CTLineCreateWithAttributedString(attibutedString)
            let rect = CTLineGetBoundsWithOptions(line, .useGlyphPathBounds)
            let width = rect.width

            var margin: CGFloat = 0
            switch textAlignment {
            case .center:
                margin = (maxWidth - width) / 2
            case .right:
                margin = maxWidth - width
            default:
                break
            }

            let runArray: [CTRun] = cfArraytoArray(sourceArray: CTLineGetGlyphRuns(line))
            for run in runArray {
                let fontPointer = CFDictionaryGetValue(CTRunGetAttributes(run), Unmanaged.passUnretained(kCTFontAttributeName).toOpaque())
                let runFont = unsafeBitCast(fontPointer, to: CTFont.self)

                for index in 0..<CTRunGetGlyphCount(run) {
                    let thisGlyphRange = CFRange(location: index, length: 1)
                    var glyph = CGGlyph()
                    var position = CGPoint()
                    CTRunGetGlyphs(run, thisGlyphRange, &glyph)
                    CTRunGetPositions(run, thisGlyphRange, &position)

                    guard let letter = CTFontCreatePathForGlyph(runFont, glyph, nil) else { continue }
                    let px = position.x + margin
                    let py = position.y + path.bounds.height + ((i == 0) ? 0 : lineSpacing)
                    let t = CGAffineTransform(translationX: px, y: py)
                    letters.addPath(letter, transform: t)
                }
            }

            path.append(UIBezierPath(cgPath: letters))
        }

        return path
    }

    private func cfArraytoArray<T>(sourceArray: CFArray) -> [T] {
        var destinationArray = [T]()
        let count = CFArrayGetCount(sourceArray)
        destinationArray.reserveCapacity(count)
        for index in 0..<count {
            let untypedValue = CFArrayGetValueAtIndex(sourceArray, index)
            let value = unsafeBitCast(untypedValue, to: T.self)
            destinationArray.append(value)
        }
        return destinationArray
    }
}
```

最初実装したときになぜか上下反転して表示されてしまった。 
![why](https://user-images.githubusercontent.com/5406126/53010047-2dc73900-3480-11e9-9ac6-904efe901dfc.png)

transformがうまくいっていなかったようなので、 `UIBezierPath`のextensionを実装した。
extensionの実装は以下の通り。

```swift
import UIKit

extension UIBezierPath {
    enum InvertDirection {
        case none
        case x
        case y
        case both
    }

    func flip(direction: InvertDirection) {
        let rect = self.bounds
        switch direction {
        case .none:
            break
        case .x:
            self.apply(CGAffineTransform(translationX: -rect.origin.x, y: 0))
            self.apply(CGAffineTransform(scaleX: -1, y: 1))
            self.apply(CGAffineTransform(translationX: rect.origin.x + rect.width, y: 0))
        case .y:
            self.apply(CGAffineTransform(translationX: 0, y: -rect.origin.y))
            self.apply(CGAffineTransform(scaleX: 1, y: -1))
            self.apply(CGAffineTransform(translationX: 0, y: rect.origin.y + rect.height))
        case .both:
            self.apply(CGAffineTransform(translationX: -rect.origin.x, y: -rect.origin.y))
            self.apply(CGAffineTransform(scaleX: -1, y: -1))
            self.apply(CGAffineTransform(translationX: rect.origin.x + rect.width, y: rect.origin.y + rect.height))
        }
    }
}
```

## 使い方

```swift
import UIKit

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        // For example
        let str = "我が家の猫は7.4kg\nゆえに重い"
        guard let font = UIFont(name: "HiraKakuProN-W6", size: 36) else { return }

        let textOptions = TextOutlineShapeView.TextOptions(text: str,
                                                           font: font,
                                                           lineSpacing: 10,
                                                           textAlignment: .left)
        let shapeOptions = TextOutlineShapeView.ShapeOptions(lineJoin: .round,
                                                             fillColor: UIColor.white.cgColor,
                                                             strokeColor: UIColor.blue.cgColor,
                                                             lineWidth: 5,
                                                             shadowColor: UIColor.black.cgColor,
                                                             shadowOffset: CGSize(width: 3, height: 3),
                                                             shadowRadius: 5,
                                                             shadowOpacity: 0.6)
        let shapeView = TextOutlineShapeView(textOptions: textOptions, shapeOptions: shapeOptions)
        self.view.addSubview(shapeView)

        shapeView.translatesAutoresizingMaskIntoConstraints = false
        let top = shapeView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 10)
        let bottom = shapeView.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: 10)
        let leading = shapeView.leadingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.leadingAnchor, constant: 10)
        let trailing = shapeView.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.trailingAnchor, constant: 10)
        NSLayoutConstraint.activate([top, bottom, leading, trailing])
    }
}
```

![sample](https://user-images.githubusercontent.com/5406126/53006951-a676c700-3479-11e9-8c4a-6ef9eabab0cb.png)

いい感じ。
実用するなら描画後に画像化したほうがパフォーマンスがいいと思う。

## 大きく表示したサンプル
角の処理結果が見づらいので、文字サイズを大きくした。

`NSAttributedString` で作ったもの。美しくない…。
![bad](https://user-images.githubusercontent.com/5406126/53008167-200fb480-347c-11e9-983b-0409fa8857ce.png)  
  
`UIBezierPath` で作ったもの。美しい。
![good](https://user-images.githubusercontent.com/5406126/53008168-200fb480-347c-11e9-9a87-70f1a60d5552.png)

## ソースコード
https://github.com/macneko-ayu/TextOutlineShapeView