SwiftUI で吹き出しを描く。自由自在に。

## はじめに

SwiftUI で吹き出し（ChatBubble）を描きたかった。ググると、いくらでもサンプルは出てくるが、複雑で何やってるのかよく分からない。よく分からないので、カスタマイズできない。

というわけで、どうやって描いてるのかをしっかり理解し、応用が効くようにしたい。

という趣旨。

![Screenshot 2025-01-03 at 16.12.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/01003dad-5842-4850-0f9e-13a9a0114f98.png)
こういうのを描く。
上のようなタイプの吹き出しは、ググるとサンプルがたくさん出てくるヤツで、メッセージアプリなどでは重宝するが、キャラクターに喋らせたいとか、そういう場合には微妙な場合がある。吹き出す位置をカスタマイズしたかった。


## UIBezierPath の基本

```swift
import SwiftUI

struct ChatBubble: Shape {
    func path(in rect: CGRect) -> Path {
        // path の描画
        let path = UIBezierPath()
        path.move(to: ...) // path の始点へ移動
        // path 追加の処理
        path.close() // path を閉じる（path の始点へ直線が引かれる）
        return Path(path.cgPath)
    }
}
```

パスを描き始めたい場所まで行って、パスを追加して、閉じる。
こんなことを行うのが基本だ。

移動には座標が必要になるため、座標を設定すると、以下のような感じになる。

```swift
import SwiftUI

struct ChatBubble: Shape {
    func path(in rect: CGRect) -> Path {
        // 座標の設定
        let shapeRect = CGRect(
            x: 0, y: 0, width: rect.width, height: rect.height
        )
        let left = shapeRect.minX
        let right = shapeRect.maxX
        let top = shapeRect.minY
        let bottom = shapeRect.maxY

        // Path の描画
        let path = UIBezierPath()
        path.move(to: CGPoint(x: left, y: top)) // path の始点へ移動
        // path 追加の処理
        path.close() // path を閉じる（path の始点へ直線が引かれる）
        return Path(path.cgPath)
    }
}
```

![Screenshot 2025-01-03 at 13.38.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/e1663f7f-26cd-a8dc-ff7a-d3a1bf8def0d.png)


## addLine で四角形(Rectangle)を描いてみる

addLine は、今いる位置から、指定した位置まで直線を引くので、以下の図のような手順でパスを描いてあげれば良い。

![Screenshot 2025-01-03 at 13.11.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/a472caeb-730c-d81e-fe8b-2ee75fedd142.png)


```swift
        let path = UIBezierPath()
        path.move(to: CGPoint(x: left, y: top)) // 1. path の始点へ移動
        path.addLine(to: CGPoint(x: right, y: top)) // 2. 左上から右上へ直線を描く
        path.addLine(to: CGPoint(x: right, y: bottom)) // 3. 右上から右下へ直線を描く
        path.addLine(to: CGPoint(x: left, y: bottom)) // 4. 右下から左下へ直線を描く
        path.close() // path を閉じる（右下から右上へ直線を描く）
```

Preview は画面いっぱいになって分かりづらいので、frame を指定しておく。

```swift
#Preview {
    ChatBubble()
        .frame(width: 300, height: 100)
}
```

## addLine と addCurve で角丸四角形(RoundedRectangle)を描いてみる

addCurve は、

```swift
func addCurve(
    to endPoint: CGPoint,
    controlPoint1: CGPoint,
    controlPoint2: CGPoint
)
```

という関数になっており、

![Screenshot 2025-01-03 at 13.18.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/d322a83a-4bbc-db6b-3888-f1b1e848409e.png)

こういうことらしい。

角丸の半径を radius として与えた場合、

![Screenshot 2025-01-03 at 13.29.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/c5155a41-6faa-e709-e548-00b5c8d52306.png)

こんなようなことをすれば良い。まず、左上の角丸を描いてみる。

```swift
        // 始点をy軸方向に、radius 分ずらす
        path.move(to: CGPoint(x: left, y: top + radius))
        path.addCurve(
            // カーブの終点を、x軸方向に raidus 分進んだところに設定する
            to: CGPoint(x: left + radius, y: top),
            // controlPoint1, 2 は同じ場所に
            controlPoint1: CGPoint(x: left, y: top),
            controlPoint2: CGPoint(x: left, y: top)
        )
        path.addLine(to: CGPoint(x: right, y: top))
        path.addLine(to: CGPoint(x: right, y: bottom))
        path.addLine(to: CGPoint(x: left, y: bottom))
        path.close()
```

![Screenshot 2025-01-03 at 13.36.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/a3b9bd35-db27-ba79-77e3-26b339118dbd.png)

描けた。

以下の手順で、全ての角を丸くする。
![Screenshot 2025-01-03 at 13.48.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/2fbe35fa-414a-cab2-f30a-df625139d547.png)

```swift
        path.move(to: CGPoint(x: left, y: top + radius))
        // top left corner
        path.addCurve(
            to: CGPoint(x: left + radius, y: top),
            controlPoint1: CGPoint(x: left, y: top),
            controlPoint2: CGPoint(x: left, y: top)
        )
        path.addLine(to: CGPoint(x: right - radius, y: top))
        // top right corner
        path.addCurve(
            to: CGPoint(x: right, y: top + radius),
            controlPoint1: CGPoint(x: right, y: top),
            controlPoint2: CGPoint(x: right, y: top)
        )
        path.addLine(to: CGPoint(x: right, y: bottom - radius))
        // bottom right corner
        path.addCurve(
            to: CGPoint(x: right - radius, y: bottom),
            controlPoint1: CGPoint(x: right, y: bottom),
            controlPoint2: CGPoint(x: right, y: bottom)
        )
        path.addLine(to: CGPoint(x: left + radius, y: bottom))
        // bottom left corner
        path.addCurve(
            to: CGPoint(x: left, y: bottom - radius),
            controlPoint1: CGPoint(x: left, y: bottom),
            controlPoint2: CGPoint(x: left, y: bottom)
        )
        path.close()
```

![Screenshot 2025-01-03 at 13.54.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/bff0d613-d33d-7eb8-16f1-da81ee7a201f.png)

Good.

あとは尻尾 (arrow) を生やせば完成だ。

## 吹き出しをつける（メッセージ風）

まずは、left の座標を arrowWidth 分、ずらす。

![Screenshot 2025-01-03 at 14.32.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/9c873359-3d72-90bd-406e-753ac1a28086.png)

いままでの left の位置は、0になる。

```swift
        let left = shapeRect.minX + arrowWidth
```

これで既存のコードそのままに、左側に arrowWidth 分の余白ができた。
そして、最後の addCurve と close の間に、2つ addCurve する。

![Screenshot 2025-01-03 at 14.27.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/40cacd99-ff43-35db-8563-ec8f5ca6c997.png)

こう。

controlPoint をどうするか？が問題なのだが、三平方の定理を駆使して計算して出すと、なんだかカクカクしてしまい、イマイチなので、ここは大胆にさじ加減で笑
適当に調整しながら、良さそうな controlPoint を見つける。ここにはロジックはない。

```swift
        // bottom left corner
        path.addCurve(
            to: CGPoint(
                x: left + radius - radius / sqrt(2),
                y: bottom - radius + radius / sqrt(2)
            ),
            controlPoint1: CGPoint(x: left + radius * 0.9, y: bottom),
            controlPoint2: CGPoint(x: left + radius * 0.2, y: bottom)
        )
        path.addCurve(
            to: CGPoint(x: 0, y: bottom),
            controlPoint1: CGPoint(x: left + radius * 0.15, y: bottom),
            controlPoint2: CGPoint(x: left, y: bottom)
        )
        path.addCurve(
            to: CGPoint(x: left, y: bottom - arrowHeight),
            controlPoint1: CGPoint(x: left, y: bottom - radius * 0.1),
            controlPoint2: CGPoint(
                x: left, y: bottom - arrowHeight + radius * 0.8)
        )
```

![Screenshot 2025-01-03 at 15.26.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/8aff2a9d-75cd-1c95-5172-db55e81b3634.png)

まぁまぁ良い感じではないだろうか。

パスをしっかり見るために、Preview をいじった。

```swift
#Preview {
    ChatBubble()
        .stroke(.black, lineWidth: 2)
        .frame(width: 300, height: 100)
}
```

以下、プログラム全文になる。実際に使う場合は、radius に大きな値を入れると現代アートのようになってしまうため、その辺りの制御は入れる必要がある。
吹き出す位置を変えたい場合は、都度描いても良いが（よくない）、rotation3DEffect で回転させれば良い。

```swift:ChatBubble.swift
import SwiftUI

struct ChatBubble: Shape {
    var radius: CGFloat = 30
    var arrowWidth: CGFloat = 20
    var arrowHeight: CGFloat = 40

    func path(in rect: CGRect) -> Path {
        let shapeRect = CGRect(
            x: 0, y: 0, width: rect.width, height: rect.height
        )
        let left = shapeRect.minX + arrowWidth
        let right = shapeRect.maxX
        let top = shapeRect.minY
        let bottom = shapeRect.maxY

        let path = UIBezierPath()
        path.move(to: CGPoint(x: left, y: top + radius))
        // top left corner
        path.addCurve(
            to: CGPoint(x: left + radius, y: top),
            controlPoint1: CGPoint(x: left, y: top),
            controlPoint2: CGPoint(x: left, y: top)
        )
        path.addLine(to: CGPoint(x: right - radius, y: top))
        // top right corner
        path.addCurve(
            to: CGPoint(x: right, y: top + radius),
            controlPoint1: CGPoint(x: right, y: top),
            controlPoint2: CGPoint(x: right, y: top)
        )
        path.addLine(to: CGPoint(x: right, y: bottom - radius))
        // bottom right corner
        path.addCurve(
            to: CGPoint(x: right - radius, y: bottom),
            controlPoint1: CGPoint(x: right, y: bottom),
            controlPoint2: CGPoint(x: right, y: bottom)
        )
        path.addLine(to: CGPoint(x: left + radius, y: bottom))
        // bottom left corner
        path.addCurve(
            to: CGPoint(
                x: left + radius - radius / sqrt(2),
                y: bottom - radius + radius / sqrt(2)
            ),
            controlPoint1: CGPoint(x: left + radius * 0.9, y: bottom),
            controlPoint2: CGPoint(x: left + radius * 0.2, y: bottom)
        )
        path.addCurve(
            to: CGPoint(x: 0, y: bottom),
            controlPoint1: CGPoint(x: left + radius * 0.15, y: bottom),
            controlPoint2: CGPoint(x: left, y: bottom)
        )
        path.addCurve(
            to: CGPoint(x: left, y: bottom - arrowHeight),
            controlPoint1: CGPoint(x: left, y: bottom - radius * 0.1),
            controlPoint2: CGPoint(
                x: left, y: bottom - arrowHeight + radius * 0.8)
        )
        path.close()
        return Path(path.cgPath)
    }
}

#Preview {
    ChatBubble()
        .stroke(.black, lineWidth: 2)
        .frame(width: 300, height: 100)
}
```

## 吹き出しをつける（セリフ風）

さて、私的に今回欲しかったセリフ風の吹き出しを考える。カーブの途中から生えないようにするので、メッセージ風よりは簡単だ。

![Screenshot 2025-01-03 at 15.45.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/53d14f68-4bb0-e70c-bf75-920ea0ea501e.png)

前述の RoundedRectangle までコードは戻り、最後のカーブの後に、パスを追加する。

で、あとはさじ加減にて。

```swift
        // bottom left corner
        path.addCurve(
            to: CGPoint(x: left, y: bottom - radius),
            controlPoint1: CGPoint(x: left, y: bottom),
            controlPoint2: CGPoint(x: left, y: bottom)
        )
        // arrow
        path.addLine(to: CGPoint(x: left, y: top + radius * 0.8 + arrowHeight))
        path.addCurve(
            to: CGPoint(x: 0, y: top + radius + arrowHeight),
            controlPoint1: CGPoint(x: left, y: top + radius + arrowHeight),
            controlPoint2: CGPoint(x: 0, y: top + radius + arrowHeight)
        )
        path.addCurve(
            to: CGPoint(x: left, y: top + radius),
            controlPoint1: CGPoint(x: left, y: top + radius + arrowHeight),
            controlPoint2: CGPoint(x: left, y: top + arrowHeight)
        )
```

![Screenshot 2025-01-03 at 16.12.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/01003dad-5842-4850-0f9e-13a9a0114f98.png)

汎用的に使えるようにするには、色々修正が必要なのだけど、たぶん、吹き出しがどういう風に描かれているかを理解することが目的なので、いったんこれにて良いことにする。radius が大きくなって丸くなっても行けるようにするとか、そういったモチベーションは今のところない。

## 使用例

```swift
    Text("新年あけましておめでとうございます。旧年中は大変お世話になりました。本年も何卒よろしくお願いいたします。\n\nお年玉ください。")
        .padding()
        .padding(.leading, 20)  // arrowWidth 分だけ先頭に padding
        .frame(width: 300)
        .background(.green)
        .clipShape(
            CustomChatBubble(radius: 20, arrowWidth: 20, arrowHeight: 80)
        )
```

![Screenshot 2025-01-03 at 16.36.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/b7795718-3425-4fd8-fdf1-3ab6ecf0b74f.png)

新年あけましておめでとうございます。は重複表記では？？言語は進化するのです。良いのです。

ではでは。