【Swift】UILabelにLayerを追加して吹き出しをつける

# はじめに
UILabelに吹き出しをつけようと思い、いろいろと調べた中で画像を利用せずLayerを追加することで実現できることがわかったため、共有します。
これまでは吹き出し用の画像を文字数に合わせリサイズしていたのですが、サイズ調整がうまくいかず綺麗に描画できなかったため、こちらの方式に切り替えました。

実現イメージ
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/192874/36331b3c-a2dc-a073-e119-5dad5cab2cd6.png", width="400">

# 実装方針
1. 吹き出し用のBezierPathで吹き出しのPathをつくる
2. CAShapeLayerに吹き出しのPathを設定
3. UILabelのSubLayerにCAShapeLayerを足す

※[こちら](https://medium.com/@dimabauer/creating-a-chat-bubble-which-looks-like-a-chat-bubble-in-imessage-the-advanced-way-2d7497d600ba)の記事を参考にしています

# コードの全量
UILabelのExtensionとして実装したコードの全量は以下になります。

```swift
extension UILabel {
    static let chatBubbleLayerName = "chatBubble"
    
    func addChatBubble(isIncoming: Bool, filledWith color: UIColor) {
        
        if let sublayers = layer.sublayers {
            for sublayer in sublayers {
                if let name = sublayer.name, name == UILabel.chatBubbleLayerName {
                    return
                }
            }
        }
        
        let horizontalMargin: CGFloat = 10
        let verticalMargin: CGFloat = 6
        let width: CGFloat = frame.size.width + horizontalMargin * 2
        let height: CGFloat = frame.size.height + verticalMargin * 2
        
        let bezierPath = UIBezierPath()
        if isIncoming {
            bezierPath.move(to: CGPoint(x: 22, y: height))
            bezierPath.addLine(to: CGPoint(x: width - 17, y: height))
            bezierPath.addCurve(to: CGPoint(x: width, y: height - 17), controlPoint1: CGPoint(x: width - 7.61, y: height), controlPoint2: CGPoint(x: width, y: height - 7.61))
            bezierPath.addLine(to: CGPoint(x: width, y: 17))
            bezierPath.addCurve(to: CGPoint(x: width - 17, y: 0), controlPoint1: CGPoint(x: width, y: 7.61), controlPoint2: CGPoint(x: width - 7.61, y: 0))
            bezierPath.addLine(to: CGPoint(x: 21, y: 0))
            bezierPath.addCurve(to: CGPoint(x: 4, y: 17), controlPoint1: CGPoint(x: 11.61, y: 0), controlPoint2: CGPoint(x: 4, y: 7.61))
            bezierPath.addLine(to: CGPoint(x: 4, y: height - 11))
            bezierPath.addCurve(to: CGPoint(x: 0, y: height), controlPoint1: CGPoint(x: 4, y: height - 1), controlPoint2: CGPoint(x: 0, y: height))
            bezierPath.addLine(to: CGPoint(x: -0.05, y: height - 0.01))
            bezierPath.addCurve(to: CGPoint(x: 11.04, y: height - 4.04), controlPoint1: CGPoint(x: 4.07, y: height + 0.43), controlPoint2: CGPoint(x: 8.16, y: height - 1.06))
            bezierPath.addCurve(to: CGPoint(x: 22, y: height), controlPoint1: CGPoint(x: 16, y: height), controlPoint2: CGPoint(x: 19, y: height))
            
        } else {
            bezierPath.move(to: CGPoint(x: width - 22, y: height))
            bezierPath.addLine(to: CGPoint(x: 17, y: height))
            bezierPath.addCurve(to: CGPoint(x: 0, y: height - 17), controlPoint1: CGPoint(x: 7.61, y: height), controlPoint2: CGPoint(x: 0, y: height - 7.61))
            bezierPath.addLine(to: CGPoint(x: 0, y: 17))
            bezierPath.addCurve(to: CGPoint(x: 17, y: 0), controlPoint1: CGPoint(x: 0, y: 7.61), controlPoint2: CGPoint(x: 7.61, y: 0))
            bezierPath.addLine(to: CGPoint(x: width - 21, y: 0))
            bezierPath.addCurve(to: CGPoint(x: width - 4, y: 17), controlPoint1: CGPoint(x: width - 11.61, y: 0), controlPoint2: CGPoint(x: width - 4, y: 7.61))
            bezierPath.addLine(to: CGPoint(x: width - 4, y: height - 11))
            bezierPath.addCurve(to: CGPoint(x: width, y: height), controlPoint1: CGPoint(x: width - 4, y: height - 1), controlPoint2: CGPoint(x: width, y: height))
            bezierPath.addLine(to: CGPoint(x: width + 0.05, y: height - 0.01))
            bezierPath.addCurve(to: CGPoint(x: width - 11.04, y: height - 4.04), controlPoint1: CGPoint(x: width - 4.07, y: height + 0.43), controlPoint2: CGPoint(x: width - 8.16, y: height - 1.06))
            bezierPath.addCurve(to: CGPoint(x: width - 22, y: height), controlPoint1: CGPoint(x: width - 16, y: height), controlPoint2: CGPoint(x: width - 19, y: height))
        }
        bezierPath.close()
        
        let chatBubbleLayer = CAShapeLayer()
        chatBubbleLayer.path = bezierPath.cgPath
        chatBubbleLayer.fillColor = color.cgColor
        let adjust: CGFloat = 4
        if isIncoming {
            chatBubbleLayer.frame = CGRect((0 - horizontalMargin), (0 - verticalMargin), width - adjust, height)
        } else {
            chatBubbleLayer.frame = CGRect((0 - horizontalMargin + adjust), (0 - verticalMargin), width - adjust, height)
        }
        chatBubbleLayer.name = UILabel.chatBubbleLayerName
        
        layer.insertSublayer(chatBubbleLayer, at: 0)
    }
    
    func removeChatBubble() {
        guard let sublayers = layer.sublayers else {
            return
        }
        
        for sublayer in sublayers {
            if let name = sublayer.name, name == UILabel.chatBubbleLayerName {
                sublayer.removeFromSuperlayer()
                return
            }
        }
    }
}
```

# コードの解説
`isIncoming`は吹き出しの向きを示しています。`true`ら相手からのチャットメッセージの向き。

```swift
func addChatBubble(isIncoming: Bool, filledWith color: UIColor)
}
```

同じ吹き出しレイヤーが追加されないようするためのチェック処理。

```swift
if let sublayers = layer.sublayers {
    for sublayer in sublayers {
        if let name = sublayer.name, name == UILabel.chatBubbleLayerName {
            return
        }
    }
}
```

吹き出しとUILabelのテキストの余白、吹き出しのサイズをここで設定しています。

```swift
let horizontalMargin: CGFloat = 10
let verticalMargin: CGFloat = 6
let width: CGFloat = frame.size.width + horizontalMargin * 2
let height: CGFloat = frame.size.height + verticalMargin * 2
```
BezierPathで吹き出しのPathを作っていきます。
吹き出しの直線部分を上記の`width`と`height`を使って調整しています。

```swift
let bezierPath = UIBezierPath()
if isIncoming {
    bezierPath.move(to: CGPoint(x: 22, y: height))
    bezierPath.addLine(to: CGPoint(x: width - 17, y: height))
    bezierPath.addCurve(to: CGPoint(x: width, y: height - 17), controlPoint1: CGPoint(x: width - 7.61, y: height), controlPoint2: CGPoint(x: width, y: height - 7.61))
    bezierPath.addLine(to: CGPoint(x: width, y: 17))
    bezierPath.addCurve(to: CGPoint(x: width - 17, y: 0), controlPoint1: CGPoint(x: width, y: 7.61), controlPoint2: CGPoint(x: width - 7.61, y: 0))
    bezierPath.addLine(to: CGPoint(x: 21, y: 0))
    bezierPath.addCurve(to: CGPoint(x: 4, y: 17), controlPoint1: CGPoint(x: 11.61, y: 0), controlPoint2: CGPoint(x: 4, y: 7.61))
    bezierPath.addLine(to: CGPoint(x: 4, y: height - 11))
    bezierPath.addCurve(to: CGPoint(x: 0, y: height), controlPoint1: CGPoint(x: 4, y: height - 1), controlPoint2: CGPoint(x: 0, y: height))
    bezierPath.addLine(to: CGPoint(x: -0.05, y: height - 0.01))
    bezierPath.addCurve(to: CGPoint(x: 11.04, y: height - 4.04), controlPoint1: CGPoint(x: 4.07, y: height + 0.43), controlPoint2: CGPoint(x: 8.16, y: height - 1.06))
    bezierPath.addCurve(to: CGPoint(x: 22, y: height), controlPoint1: CGPoint(x: 16, y: height), controlPoint2: CGPoint(x: 19, y: height))
    
} else {
    bezierPath.move(to: CGPoint(x: width - 22, y: height))
    bezierPath.addLine(to: CGPoint(x: 17, y: height))
    bezierPath.addCurve(to: CGPoint(x: 0, y: height - 17), controlPoint1: CGPoint(x: 7.61, y: height), controlPoint2: CGPoint(x: 0, y: height - 7.61))
    bezierPath.addLine(to: CGPoint(x: 0, y: 17))
    bezierPath.addCurve(to: CGPoint(x: 17, y: 0), controlPoint1: CGPoint(x: 0, y: 7.61), controlPoint2: CGPoint(x: 7.61, y: 0))
    bezierPath.addLine(to: CGPoint(x: width - 21, y: 0))
    bezierPath.addCurve(to: CGPoint(x: width - 4, y: 17), controlPoint1: CGPoint(x: width - 11.61, y: 0), controlPoint2: CGPoint(x: width - 4, y: 7.61))
    bezierPath.addLine(to: CGPoint(x: width - 4, y: height - 11))
    bezierPath.addCurve(to: CGPoint(x: width, y: height), controlPoint1: CGPoint(x: width - 4, y: height - 1), controlPoint2: CGPoint(x: width, y: height))
    bezierPath.addLine(to: CGPoint(x: width + 0.05, y: height - 0.01))
    bezierPath.addCurve(to: CGPoint(x: width - 11.04, y: height - 4.04), controlPoint1: CGPoint(x: width - 4.07, y: height + 0.43), controlPoint2: CGPoint(x: width - 8.16, y: height - 1.06))
    bezierPath.addCurve(to: CGPoint(x: width - 22, y: height), controlPoint1: CGPoint(x: width - 16, y: height), controlPoint2: CGPoint(x: width - 19, y: height))
}
bezierPath.close()
```

最後にCAShapeLayerに吹き出しのPathと色を設定し、SubLayerの最下層にインサートします。
吹き出しが左右非対称のためマージンのとりかたを吹き出しの向きによって変えています。

```swift
let chatBubbleLayer = CAShapeLayer()
chatBubbleLayer.path = bezierPath.cgPath
chatBubbleLayer.fillColor = color.cgColor
let adjust: CGFloat = 4
if isIncoming {
    chatBubbleLayer.frame = CGRect((0 - horizontalMargin), (0 - verticalMargin), width - adjust, height)
} else {
    chatBubbleLayer.frame = CGRect((0 - horizontalMargin + adjust), (0 - verticalMargin), width - adjust, height)
}
chatBubbleLayer.name = UILabel.chatBubbleLayerName

layer.insertSublayer(chatBubbleLayer, at: 0)
```

吹き出し内の文字数が変わる場合は一度削除して再描画したかったので、吹き出しレイヤーの削除メソッドも用意しました。

```swift
func removeChatBubble() {
    guard let sublayers = layer.sublayers else {
        return
    }
    
    for sublayer in sublayers {
        if let name = sublayer.name, name == UILabel.chatBubbleLayerName {
            sublayer.removeFromSuperlayer()
            return
        }
    }
}
```