はじめに
SwiftUI で吹き出し(ChatBubble)を描きたかった。ググると、いくらでもサンプルは出てくるが、複雑で何やってるのかよく分からない。よく分からないので、カスタマイズできない。
というわけで、どうやって描いてるのかをしっかり理解し、応用が効くようにしたい。
という趣旨。
こういうのを描く。
上のようなタイプの吹き出しは、ググるとサンプルがたくさん出てくるヤツで、メッセージアプリなどでは重宝するが、キャラクターに喋らせたいとか、そういう場合には微妙な場合がある。吹き出す位置をカスタマイズしたかった。
UIBezierPath の基本
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)
}
}
パスを描き始めたい場所まで行って、パスを追加して、閉じる。
こんなことを行うのが基本だ。
移動には座標が必要になるため、座標を設定すると、以下のような感じになる。
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)
}
}
addLine で四角形(Rectangle)を描いてみる
addLine は、今いる位置から、指定した位置まで直線を引くので、以下の図のような手順でパスを描いてあげれば良い。
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 を指定しておく。
#Preview {
ChatBubble()
.frame(width: 300, height: 100)
}
addLine と addCurve で角丸四角形(RoundedRectangle)を描いてみる
addCurve は、
func addCurve(
to endPoint: CGPoint,
controlPoint1: CGPoint,
controlPoint2: CGPoint
)
という関数になっており、
こういうことらしい。
角丸の半径を radius として与えた場合、
こんなようなことをすれば良い。まず、左上の角丸を描いてみる。
// 始点を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()
描けた。
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()
Good.
あとは尻尾 (arrow) を生やせば完成だ。
吹き出しをつける(メッセージ風)
まずは、left の座標を arrowWidth 分、ずらす。
いままでの left の位置は、0になる。
let left = shapeRect.minX + arrowWidth
これで既存のコードそのままに、左側に arrowWidth 分の余白ができた。
そして、最後の addCurve と close の間に、2つ addCurve する。
こう。
controlPoint をどうするか?が問題なのだが、三平方の定理を駆使して計算して出すと、なんだかカクカクしてしまい、イマイチなので、ここは大胆にさじ加減で笑
適当に調整しながら、良さそうな controlPoint を見つける。ここにはロジックはない。
// 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)
)
まぁまぁ良い感じではないだろうか。
パスをしっかり見るために、Preview をいじった。
#Preview {
ChatBubble()
.stroke(.black, lineWidth: 2)
.frame(width: 300, height: 100)
}
以下、プログラム全文になる。実際に使う場合は、radius に大きな値を入れると現代アートのようになってしまうため、その辺りの制御は入れる必要がある。
吹き出す位置を変えたい場合は、都度描いても良いが(よくない)、rotation3DEffect で回転させれば良い。
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)
}
吹き出しをつける(セリフ風)
さて、私的に今回欲しかったセリフ風の吹き出しを考える。カーブの途中から生えないようにするので、メッセージ風よりは簡単だ。
前述の RoundedRectangle までコードは戻り、最後のカーブの後に、パスを追加する。
で、あとはさじ加減にて。
// 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)
)
汎用的に使えるようにするには、色々修正が必要なのだけど、たぶん、吹き出しがどういう風に描かれているかを理解することが目的なので、いったんこれにて良いことにする。radius が大きくなって丸くなっても行けるようにするとか、そういったモチベーションは今のところない。
使用例
Text("新年あけましておめでとうございます。旧年中は大変お世話になりました。本年も何卒よろしくお願いいたします。\n\nお年玉ください。")
.padding()
.padding(.leading, 20) // arrowWidth 分だけ先頭に padding
.frame(width: 300)
.background(.green)
.clipShape(
CustomChatBubble(radius: 20, arrowWidth: 20, arrowHeight: 80)
)
新年あけましておめでとうございます。は重複表記では??言語は進化するのです。良いのです。
ではでは。