InstagramのStoryにのせる文字みたいなUITextView

Instagram のストーリーを投稿しようとしてたときに、ふと「この文字を縁取る白い背景はどうやって実現しているんだろう」と思い、作ってみました。

|Instagram Story|今回作ったやつ|
|:--:|:--:|
|![uploaded-1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42826/1b7ba8c0-207e-645f-eb8a-9d136c2381d0.gif)|![uploaded-2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42826/0ac1f128-7983-91cf-1f96-b0ecfd06a3fc.gif)|


# コード

下記にコメント番号に合わせて解説があります。
また、下記コードだけでは動きません。解説内の `extension` を併用してください！

```swift
class InstagramTextView: UITextView, UITextViewDelegate {
    
    let cornerRadius: CGFloat = 8
    let highlightColor: UIColor = UIColor.white
    
    override init(frame: CGRect, textContainer: NSTextContainer?) {
        super.init(frame: frame, textContainer: textContainer)
        initialize()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        initialize()
    }
    
    private func initialize() {
        self.delegate = self
        self.isScrollEnabled = false
        self.textContainerInset = UIEdgeInsets(top: cornerRadius, left: cornerRadius, bottom: cornerRadius, right: cornerRadius)
    }
    
    override var intrinsicContentSize: CGSize {
        self.set()
        return super.intrinsicContentSize
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        self.set()
    }
    
    func textViewDidChange(_ textView: UITextView) {
        set()
    }
    
    func set() {
        
        // 1. 各行の CGRect(rects) を取得
        var rects = self.text.lineRanges.map({ range -> CGRect in
            var rect = self.layoutManager.boundingRect(forGlyphRange: range, in: self.textContainer)
            rect.origin.x += self.textContainerInset.left
            rect.origin.y += self.textContainerInset.top
            if !self.text.substring(with: range).isEmpty {
                rect.origin.x -= cornerRadius
                rect.size.width += cornerRadius * 2
            }
            rect.origin.y -= 1
            rect.size.height += 1
            return rect
        })
        
        // 2. 左端を揃える
        repeat {
            for i in rects.indices.dropLast() {
                let diffMinX = rects[i].minX - rects[i+1].minX
                guard abs(diffMinX) < cornerRadius * 2 && diffMinX != 0 && rects[i].width != 0 && rects[i+1].width != 0 else { continue }
                
                switch diffMinX {
                case ...0:
                    rects[i+1].size.width += abs(diffMinX)
                    rects[i+1].origin.x = rects[i].origin.x
                case 0...:
                    rects[i].size.width += abs(diffMinX)
                    rects[i].origin.x = rects[i+1].origin.x
                default:
                    break
                }
            }
        } while rects.eachPair().filter({ $0.0.width != 0 && $0.1.width != 0 }).map({ abs($0.0.minX - $0.1.minX) }).contains(where: { $0 < self.cornerRadius * 2 && $0 != 0 })
        
        // 2. 右端を揃える
        repeat {
            for i in rects.indices.dropLast() {
                let diffMaxX = rects[i].maxX - rects[i+1].maxX
                guard abs(diffMaxX) < cornerRadius * 2 && diffMaxX != 0 && rects[i].width != 0 && rects[i+1].width != 0 else { continue }
                
                switch diffMaxX {
                case ...0:
                    rects[i].size.width += abs(diffMaxX)
                case 0...:
                    rects[i+1].size.width += abs(diffMaxX)
                default:
                    break
                }
            }
        } while rects.eachPair().filter({ $0.0.width != 0 && $0.1.width != 0 }).map({ abs($0.0.maxX - $0.1.maxX) }).contains(where: { $0 < self.cornerRadius * 2 && $0 != 0 })
        
        // 3. rects に対応する BumpyCorner の配列(corners)を用意
        var corners = [BumpyCorner](repeating: BumpyCorner(), count: rects.count)
        
        // 4. 最初の行の上部2つの角, 最後の行の下部2つの角, は必ず丸い
        if !corners.isEmpty {
            corners[0].topLeft = .contraction
            corners[0].topRight = .contraction
            corners[corners.count - 1].bottomLeft = .contraction
            corners[corners.count - 1].bottomRight = .contraction
        }
        
        // 4. 各行の間の角4つ
        for i in rects.indices.dropLast() {
            // 左端
            let diffMinX = rects[i].minX - rects[i+1].minX
            if diffMinX < 0 {
                // 上の行が左側に出っ張る
                corners[i].bottomLeft = .contraction
                corners[i+1].topLeft = .expansion
            } else if diffMinX > 0 {
                // 下の行が左側に出っ張る
                corners[i].bottomLeft = .expansion
                corners[i+1].topLeft = .contraction
            }
            
            // 右端
            let diffMaxX = rects[i].maxX - rects[i+1].maxX
            if diffMaxX < 0 {
                // 下の行が右側に出っ張る
                corners[i].bottomRight = .expansion
                corners[i+1].topRight = .contraction
            } else if diffMaxX > 0 {
                // 下の行が左側に出っ張る
                corners[i].bottomRight = .contraction
                corners[i+1].topRight = .expansion
            }
        }
        
        // 4. 文字のないところは真っ直ぐ
        for i in rects.indices where rects[i].width == 0 {
            corners[i].topLeft = .none
            corners[i].topRight = .none
            corners[i].bottomLeft = .none
            corners[i].bottomRight = .none
        }
        
        // 5. rects と corners を元に各行の UIBezierPath(paths) を用意
        let paths = rects.indices.map({ UIBezierPath(rect: rects[$0], bumpyCorner: corners[$0], cornerRadius: cornerRadius) })
        
        // 6. paths を元に CAShapeLayer を用意
        self.layer.sublayers?.filter({ $0.name == "highlight" }).forEach({ $0.removeFromSuperlayer() })
        for path in paths {
            let shapeLayer = CAShapeLayer(fill: highlightColor, zPosition: -1, path: path, frame: self.bounds, name: "highlight")
            self.layer.addSublayer(shapeLayer)
        }
    }
}
```

# 解説
コード内の各コメントの番号に対応してます。

## 前提の考え方
今回の大まかな実現手段として、
行ごとに分解する → 各行の角をどうするか(丸めたり)考える → 各行の背景にCALayerで白い背景を入れる
という発想で処理していきます。

## 1. 各行の CGRect(rects) を取得

### まず各行の NSRange を求める

下記みたいな感じで各行の `NSRange` を用意したい。

```swift

aiu     -> NSRange(location: 0, length: 3)
e       -> NSRange(location: 4, length: 1)
okakiku -> NSRange(location: 6, length: 7)
```

なので下記 extension を使用。

```swift
extension String {    
    var lineRanges: [NSRange] {
        let lines = self.components(separatedBy: .newlines)
        let ranges = lines.reduce([]) { result, line -> [NSRange] in
            let location = result.map({ $0.length }).sum() + result.count
            return result + [NSRange(location: location, length: line.count)]
        }
        return ranges
    }
}

extension Array where Element == Int {
    func sum() -> Int {
        return self.reduce(0, { $0 + $1 })
    }
}
```

### 各行の NSRange を元に CGRect を取得

各行の座標(CGRect)を取得するためには、 [`boundingRect(forGlyphRange:in:)`](https://developer.apple.com/documentation/uikit/nslayoutmanager/1403255-boundingrect) を使用します。
これらに関する解説は別記事で書きましたので御覧ください。 
https://qiita.com/yuki0n0/items/1a15bde4e75f05c99681

また、 **文字列があるとき(0文字で無い時)** のみ左右にパディングを設けるように調整を加え、
上下の行で隙間がうまれないよう 1px 上にずらしています。

### substring(with:) について

用意するまでも無いかもしれませんが、 `String` のまま書きたかったので。

```swift
extension String {
    func substring(with range: NSRange) -> String {
        return (self as NSString).substring(with: range)
    }
}
```

## 2. 端を揃える

上から2行づつ、左端の差を調べます。
もし `cornerRadius * 2` 未満のズレだと( **狭すぎると** )、角丸が実現できないので、外側の端に座標を合わせます。
下記に例を示します。

|1. 左が狭すぎる|2. 外側(2行目)に合わせて1行目を伸ばす|
|:--:|:--:|
|![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42826/5a1fdd19-86ce-abf9-4b07-f9b34e1dc0f0.png)|![2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42826/4a04701b-bea6-725a-8bb9-dd19b9a2e5a4.png)|

また、`repeat while 文` でこの処理を繰り返していますが、それは下記のような対応のためです。

### もし1度のみのチェックだったら
もし `repeat while 文` で繰り返し処理しなければ。

|① 1,2行目を比較 → 狭い|② 1行目の左端を2行目に合わせる|③ 2,3行目を比較 → 狭い|④ 2行目の左端を3行目に合わせる|
|:--:|:--:|:--:|:--:|
|![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42826/daa2019e-efd3-fe6f-f638-15b529431b2c.png)|![2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42826/29fa8832-bdd4-51a3-48b0-fee84bd9ff65.png)|![3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42826/9dc9970a-8604-9579-af80-01079f5ceaf2.png)|![4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42826/2fd7e1c3-286d-e444-cd87-2c81ba0f47cc.png)

これで終わると、1,2行目のズレが小さく、角丸の処理がおかしくなります。

### repeat while 文で繰り返し確認

上記の続きで、繰り返し処理すると、

|⑤ 1,2行目比較 → 狭い|⑥ 1行目の左端を2行目に合わせる|⑦ 2,3行目比較 → ずれがない|⑧ 処理なし|
|:--:|:--:|:--:|:--:|
|![5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42826/abbbe22a-f508-effd-28cc-46f8c8e431ab.png)|![6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42826/51880d4e-ac91-9e34-2e1a-796806e7ae10.png)|![6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42826/51880d4e-ac91-9e34-2e1a-796806e7ae10.png)|![6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42826/51880d4e-ac91-9e34-2e1a-796806e7ae10.png)|

これで小さすぎる隙間の連続が揃えられました。
ここまでの処理を左端も右端も行います。

### eachPair() について

`extension` を用意しています。詳しくは下記記事に書きました。
https://qiita.com/yuki0n0/items/af100a330a5a72cd067f

```swift
extension Array {
    func eachPair() -> [(Element, Element)] {
        return zip(self, self.dropFirst()).map({ ($0.0, $0.1) })
    }
}
```


## 3. rects に対応する BumpyCorner の配列(corners)を用意

`BumpyCorner` という構造体を用意しました。

```swift
struct BumpyCorner {
    var topLeft: Kind
    var topRight: Kind
    var bottomLeft: Kind
    var bottomRight: Kind
    
    init(topLeft: Kind = .none, topRight: Kind = .none, bottomLeft: Kind = .none, bottomRight: Kind = .none) {
        self.topLeft = topLeft
        self.topRight = topRight
        self.bottomLeft = bottomLeft
        self.bottomRight = bottomRight
    }
    
    enum Kind {
        case expansion   // 膨張
        case none        // なし
        case contraction // 収縮
    }
}
```

角における形が、3種類あります。これを各角でどうするか保持する構造体です。

|expansion|none|contraction|
|:--:|:--:|:--:|
|![c.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42826/074c2972-de07-8699-3947-922bc2d39ee4.png)|![b.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42826/37efc90c-8ff5-3ca7-d8d4-1408d15930e4.png)|![a.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42826/1eeba0df-fb3d-7ea3-7229-c19fcea64ddf.png)|

## 4. 各角の種類を判定

### 各行の間の角4つ

上から2行づつ比較し、2行間の各角 (1行目の左下/1行目の右下/2行目の左上/2行目の右上) の角をどう加工するかチェックします。下に例を示します。


|このような2行の時|1行目左下は`expansion`<br>2行目左上は`contraction`|
|:--:|:--:|
|![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42826/dd22b3a5-2fe7-77ad-3c36-ba7c899512b7.png)|![2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42826/8e6e88f2-7cd1-4fe6-be53-abfadb591399.png)|


### それ以外の角

- 最初の行の上部2つの角 / 最後の行の下部2つの角 は必ず `contraction` (丸い)
- 文字が無い行 (width==0) は `none` (まっすぐ)

## 5. rects と corners を元に各行の UIBezierPath(paths) を用意

UIBezierPath の extension を用意しました。
`BumpyCorner` を元に、各角のパスを描いていきます。
よくわからない方は調べてください。イメージ的にはただ線で輪郭を書いているだけです。

```swift
extension UIBezierPath {
    
    convenience init(rect: CGRect, bumpyCorner corner: BumpyCorner, cornerRadius radius: CGFloat) {
        self.init()
        
        self.move(to: CGPoint(x: rect.origin.x + radius, y: rect.minY))
        
        switch corner.topRight {
        case .expansion:
            self.addLine(to: CGPoint(x: rect.maxX + radius, y: rect.minY))
            self.addArc(withCenter: CGPoint(x: rect.maxX + radius, y: rect.minY + radius), radius: radius, startAngle: CGFloat.pi * 1.5, endAngle: CGFloat.pi, clockwise: false)
        case .none:
            self.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
        case .contraction:
            self.addLine(to: CGPoint(x: rect.maxX - radius, y: rect.minY))
            self.addArc(withCenter: CGPoint(x: rect.maxX - radius, y: rect.minY + radius), radius: radius, startAngle: CGFloat.pi * 1.5, endAngle: 0, clockwise: true)
        }
        
        switch corner.bottomRight {
        case .expansion:
            self.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - radius))
            self.addArc(withCenter: CGPoint(x: rect.maxX + radius, y: rect.maxY - radius), radius: radius, startAngle: CGFloat.pi, endAngle: CGFloat.pi * 0.5, clockwise: false)
        case .none:
            self.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
        case .contraction:
            self.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - radius))
            self.addArc(withCenter: CGPoint(x: rect.maxX - radius, y: rect.maxY - radius), radius: radius, startAngle: 0, endAngle: CGFloat.pi * 0.5, clockwise: true)
        }
        
        switch corner.bottomLeft {
        case .expansion:
            self.addLine(to: CGPoint(x: rect.minX - radius, y: rect.maxY))
            self.addArc(withCenter: CGPoint(x: rect.minX - radius, y: rect.maxY - radius), radius: radius, startAngle: CGFloat.pi * 0.5, endAngle: 0, clockwise: false)
        case .none:
            self.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
        case .contraction:
            self.addLine(to: CGPoint(x: rect.minX + radius, y: rect.maxY))
            self.addArc(withCenter: CGPoint(x: rect.minX + radius, y: rect.maxY - radius), radius: radius, startAngle: CGFloat.pi * 0.5, endAngle: CGFloat.pi, clockwise: true)
        }
        
        switch corner.topLeft {
        case .expansion:
            self.addLine(to: CGPoint(x: rect.minX, y: rect.minY + radius))
            self.addArc(withCenter: CGPoint(x: rect.minX - radius, y: rect.minY + radius), radius: radius, startAngle: 0, endAngle: CGFloat.pi * 1.5, clockwise: false)
        case .none:
            self.addLine(to: CGPoint(x: rect.minX, y: rect.minY))
        case .contraction:
            self.addLine(to: CGPoint(x: rect.minX, y: rect.maxY + radius))
            self.addArc(withCenter: CGPoint(x: rect.minX + radius, y: rect.minY + radius), radius: radius, startAngle: CGFloat.pi, endAngle: CGFloat.pi * 1.5, clockwise: true)
        }
        
        self.close()
    }
}
```

## 6. paths を元に CAShapeLayer を用意

用意したパスを元にレイヤーを用意して、表示させます。


```swift
extension CAShapeLayer {
    
    convenience init(fill color: UIColor?, zPosition: CGFloat = 0, path: UIBezierPath, frame: CGRect, name: String? = nil) {
        self.init()
        self.fillColor = color?.cgColor
        self.zPosition = zPosition
        self.path = path.cgPath
        self.frame = frame
        self.name = name
    }
}
```

# 環境

- Swift 5
- Xcode 10.2.1
- iOS 12.1

# 後記

業務で必要に迫られたわけではなく、興味本位でなんとなく書いてみたものなので、結構無理矢理感があります笑。
`set()` 関数内のコードがめちゃくちゃ長かったり、中途半端に extension にかき分けたり、map / filter / reduce 周りを多用したり、とてもじゃないけど綺麗なコードとは言えないと思うので、各自綺麗にリファクタリングしてみてください。

処理内容的におかしかったり、もっと良い方法があればぜひ教えてください！
そもそも行ごとに行う処理方法は良いのか、左右の端を揃える処理はどうか、などなど。
WebView で HTML CSS で実現したらめっちゃ楽だったり？

コメントお待ちしております！