SwiftUI でメッセージアプリのような吹き出しを表示する

遠い遠い昔にUIKitで書いたコードをSwiftUIで使えるように書き直し。
https://dishware.sakura.ne.jp/swift/archives/111
線の引き方の流れは過去記事の方に記載してあります。参考にしたサイトとかは無くなっちゃってるのもありますが。

#7月とかに多かったゲリラ豪雨時なんかの雷は嫌いなんですが、INZM聴いてるとリピートしている今日この頃。ラップ部分で喉痛めちゃったりしないのかな・・と心配になる。

使い方は #Preview を参照してください。shapeColor も fillColor も省略することが可能です。flip の Bool 値を true にすると反転表示になります。
iOS16 向けに作っていますが、iOS15とかでどうなるかは未確認なのでご了承ください。

import SwiftUI

struct Balloon<Content: View>: View {
    let shapeColor: Color
    let fillColor: Color?
    let flip: Bool
    let content: Content
    
    init(shapeColor: Color = .black, fillColor: Color? = nil, flip: Bool = false, @ViewBuilder content: () -> Content) {
        self.shapeColor = shapeColor
        self.fillColor = fillColor
        self.flip = flip
        self.content = content()
    }
    var body: some View {
        content
        .padding()
        .background(content: {
            BalloonShape(shapeColor: self.shapeColor, fillColor: self.fillColor)
                .rotation3DEffect(.degrees(self.flip ? 180 : 0), axis: (x: 0, y: 1, z: 0))
        })
    }
}

private struct BalloonShape: View {
    var shapeColor: Color = .black
    var fillColor: Color? = nil
    init(shapeColor: Color = .black, fillColor: Color? = nil) {
        self.shapeColor = shapeColor
        self.fillColor = fillColor
    }
    var body: some View {
        GeometryReader { proxy in
            balloonPath(proxy: proxy)
                .stroke(shapeColor, lineWidth: 1)
                .background(self.fillColor != nil ? self.fillColor : .clear)
                .clipShape(balloonPath(proxy: proxy))
        }
    }
    private func balloonPath(proxy: GeometryProxy) -> Path {
        let padding: CGFloat = 5.0
        let cornerRadius: CGFloat = 15.5
        let arrowWidth: CGFloat = 8.0
        let arrowHeight: CGFloat = 25.0
        let arrowTopCurveHeight: CGFloat = 4.0
        let shapeRect = CGRect(x: 0, y: 0, width: proxy.size.width, height: proxy.size.height)
        let balloonRect = CGRect(x: shapeRect.origin.x + padding, y: shapeRect.origin.y + padding, width: shapeRect.size.width - (padding * 2) - arrowWidth, height: shapeRect.size.height - (padding * 2))
        let leftX = balloonRect.minX + arrowWidth
        let rightX = balloonRect.maxX
        let topY = balloonRect.minY
        let bottomY = balloonRect.maxY
        return Path({ path in
            path.move(to: CGPoint(x: leftX, y: topY + cornerRadius))
            path.arc(x: leftX + cornerRadius, y: topY + cornerRadius, startAngle: 180.0, endAngle: 270.0)
            path.arc(x: rightX - cornerRadius, y: topY + cornerRadius, startAngle: 270.0, endAngle: 360.0)
            path.arc(x: rightX - cornerRadius, y: bottomY - cornerRadius, startAngle: 0.0, endAngle: 90.0)
            path.arc(x: leftX + cornerRadius, y: bottomY - cornerRadius, startAngle: 90.0, endAngle: 135.0)
            path.addQuadCurve(to: CGPoint(x: leftX - cornerRadius, y: bottomY), control: CGPoint(x: leftX - arrowWidth, y: bottomY))
            path.addQuadCurve(to: CGPoint(x: leftX, y: bottomY - arrowHeight), control: CGPoint(x: leftX, y: bottomY - arrowTopCurveHeight))
            path.closeSubpath()
        })
    }
}

extension Path {
    mutating func arc(x: CGFloat, y: CGFloat, cornerRadius: CGFloat = 15.5, startAngle: CGFloat, endAngle: CGFloat) {
        addArc(center: CGPoint(x: x, y: y), radius: cornerRadius, startAngle: Angle(radians: startAngle * CGFloat.pi / 180.0), endAngle: Angle(radians: endAngle * CGFloat.pi / 180.0), clockwise: false)
    }
}

#Preview {
    VStack {
        HStack {
            Balloon(shapeColor: .blue, fillColor: .blue) {
                Text("Knock, knock!")
                    .foregroundStyle(.white)
                    .padding(12)
            }
            Spacer()
        }.padding(.horizontal)
        HStack {
            Spacer()
            Balloon(shapeColor: .green, fillColor: .green, flip: true) {
                Text("Who’s there?")
                    .padding(12)
            }
        }.padding(.horizontal)
        HStack {
            Balloon {
                Text("I'm the new GOAT.")
                    .padding(12)
            }
            Spacer()
        }.padding(.horizontal)
    }
}

プレビューで表示すると以下のような感じになります。テキスト以外を組み込んだ時の確認はしていないのでレイアウト崩れちゃったらごめんなさい😌

TipKit風の吹き出しの場合は以下の記事に書いてあります。が、他の方のブログの方がより良いやり方が載っている気がします。

iOS17からTipKitが提供されるようなのでこの手のUIコンポーネントは短命になるかと思いますが、iOS17が広まるまでは今しばらく時...