SwiftUI で「グループスタンプ」というチャット機能を5日間で作った話
どうも、複業で「カウシェ」の iOS アプリ開発に参画させていただいているトビ(@tobi462 )です。
iOS テスト本の著者、あるいは最近では Effective SwiftUI の著者、という自己紹介が伝わりやすいでしょうか。
今回、複業という立場ながらアドベントカレンダーを執筆する機会をいただいたので、先日リリースされた グループスタンプ機能 について、どのように SwiftUI で開発・実装されたかを実際のコードを追いながら、順を追って詳細に解説してみたいと思います。
私とカウシェ
最初に軽く私とカウシェの関わりについて触れたいと思います。(興味ないよ、という方は適当に読み飛ばしていただければと思います)
カウシェには今年の6月から複業として参画させていただいています。
カウシェでは 初期の頃から SwiftUI を採用 しており、プライベートで SwiftUI を学習していた私にとって、相性の良いと感じたのが最初のきっかけでした。
ただ、私にとって決定打になった最大の理由はそのビジョンにありました。
面談いただいた時に CPO である akifumi さんからこの言葉を聞いた時、一言でいえば 痺れました 。
殆ど受け売りになってしまいますが、既存の EC サービスでは効率化によって利便性が向上している一方、ウィンドウショッピングや友人との買い物を楽しむと言った、 本来のショッピングの楽しさが失われている という側面もあります。
そういった 本来のショッピングの楽しさを取り戻しつつ、同時にお得に買い物を楽しめる世界をつくる、というある意味では壮大なチャレンジに挑もうとしている のがカウシェというスタートアップです。
後に CEO である Mon さんの情熱を聞いたり、全力で最高のプロダクトを作ろうとしている人たちと一緒に仕事を進める中で、最大限貢献したいという気持ちが高まり、週の稼働日数が2日になった現在でも全力な気持ちでお仕事させていただいています。
グループスタンプ機能とは?
さて、あまり前置きが長くなっても仕方ないので、アドベントカレンダーらしく技術的な解説をしていきたいと思います。
まず、動画を見ていただくのが分かりやすいでしょう。
スタンプのみに限定されたチャット機能 と表現するのが近いのでしょうか。
前述したとおり「カウシェ」では楽しいショッピング体験をゴールとして掲げており、その実現に向かう1つのステップとして開発されたのが今回の グループスタンプ機能 です。
気になった商品や一緒に買いたいと思った商品について、 スタンプによる気軽なコミュニケーションで「シェア買い」というイベントを楽しんでもらう ことを目的としています。
それでは、この機能がどのような流れで開発されたか、実際のソースコードを交えながら解説していきます。(一部を改変しているコードもあります)
吹き出しの Shape
SwiftUI では 小さな部品を組み合わせて大きなものを作る という思想があるので、小さめの部品から入り、少しずつ大きなところを見ていきたいと思います。多少の前後もありますが、実際の開発の進め方もほぼ同じ手順を踏んでいます。
まず最初に目に映るのは、灰色の吹き出しです。
左側が「自分以外」、右側が「自分」という立ち位置になっていますが、灰色の吹き出しはどちらも同じ形状のものが水平方向に反転しただけなので、共通のものとして実装できそうです。
このような 2D 形状を作成する道具として、SwiftUI では Shape というものが用意されています。今回は自分でカスタム形状を定義することになりますが、標準でもいくつか用意されており、とくに Circle や Cupsule などは利用頻度が高いのではないでしょうか。
VStack {
Circle().fill(Color.red)
Capsule(style: .circular).stroke(Color.blue)
}
.frame(width: 200, height: 100)
今回はカスタム形状となるので Shape
プロトコルに準拠し、以下のような形で実装することになります。
struct XxxShape: Shape {
func path(in rect: CGRect) -> Path {
// ここに`rect`に収まるパスを定義する
}
}
柔軟性をどこまで持たせるかはいつも迷いますが、今回は「角丸のサイズ」と「三角の大きさ」を外部から指定できるようにしました。
struct ChatBaloonShape: Shape {
/// 初期化。
/// - Parameters:
/// - cornerRadius: 角丸のサイズ
/// - triangleSize: 三角(吹き出し)のサイズ
init(cornerRadius: CGFloat, triangleSize: CGFloat) {
self.cornerRadius = cornerRadius
self.triangleSize = triangleSize
}
}
コードを書いていても定数として抽出したくなる値であり、外部から指定できるようにするのは殆ど手間ではなかったからです。また、現状では対応していませんが、端末の Dynamic Type の設定に合わせて調整したい場合にも便利でしょう。
コード全体は以下のようになりました。
/// グループチャット用の吹き出し
public struct ChatBaloonShape: Shape {
private var cornerRadius: CGFloat
private var triangleSize: CGFloat
/// 初期化。
/// - Parameters:
/// - cornerRadius: 角丸のサイズ
/// - triangleSize: 三角(吹き出し)のサイズ
public init(cornerRadius: CGFloat, triangleSize: CGFloat) {
self.cornerRadius = cornerRadius
self.triangleSize = triangleSize
}
public func path(in rect: CGRect) -> Path {
Path { path in
// 右上角丸
path.addArc(
center: CGPoint(
x: rect.maxX - cornerRadius,
y: rect.minY + cornerRadius
),
radius: cornerRadius,
startAngle: Angle(degrees: 270),
endAngle: Angle(degrees: 0),
clockwise: false
)
// 右下角丸
path.addArc(
center: CGPoint(
x: rect.maxX - cornerRadius,
y: rect.maxY - cornerRadius
),
radius: cornerRadius,
startAngle: Angle(degrees: 0),
endAngle: Angle(degrees: 90),
clockwise: false
)
// 左下角丸
path.addArc(
center: CGPoint(
x: rect.minX + cornerRadius + triangleSize,
y: rect.maxY - cornerRadius
),
radius: cornerRadius,
startAngle: Angle(degrees: 90),
endAngle: Angle(degrees: 180),
clockwise: false
)
// 左上三角(吹き出し)
path.addLine(to: CGPoint(x: triangleSize, y: triangleSize))
path.addLine(to: .zero)
path.closeSubpath()
}
}
}
今回の形状はシンプルで、コード内容も複雑ではないので解説は省こうと思います。このあたりは調べればたくさんの情報が出てきますし、コード内容からも想像がつくかと思います。
Preview は以下のように書かれており、期待どおりに動いていることが確認できます。
struct ChatBaloonShape_Previews: PreviewProvider {
static var previews: some View {
VStack {
Group {
ChatBaloonShape(cornerRadius: 16, triangleSize: 8)
.stroke(Color.black)
ChatBaloonShape(cornerRadius: 32, triangleSize: 16)
.fill(Color.red.opacity(0.3))
}
.frame(width: 200, height: 140)
}
}
}
ちなみにカウシェでは UI 系の共通モジュールとして KaucheDesignKit
を作成しており、これもその中に定義しています。(Initializer が public
に定義されているのはこれが理由です)
吹き出し View
吹き出し用の Shape が作成できたので、次にスタンプ投稿の吹き出し View を作成したいと思います。
先ほどの Shape を灰色に塗りつぶし、その中にスタンプの画像を乗せればよさそうですが、自分と他の人とで吹き出しの方向が違うことが分かります。今回は SwiftUI の多くの API に習って enum
で指定できるようにしてみました。
enum AllowEdge {
case leading
case trailing
}
この値によって Shape を反転させる必要がありますが、それは rotation3DEffect
というモディファイアを利用して、Y軸で 180° 反転させる方法が使えます。
例えば、 Hello, world
という Text
を水平方向に反転させるコードは以下のようになります。
Text("Hello, world")
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
これをそのまま利用しても良いですが、こうした「水平方向に反転する」という使い方は多そうですし、可読性の面からもそれが簡単に読み取れると理想的です。
そこで、View の extension にて以下のような関数を定義しました。
extension View {
/// Y軸で反転させる。
/// - Parameter enabled: `true`なら反転。`false`ならそのまま。(デフォルトは`true`)
/// - Returns: 結果
func horizontalFlipped(_ enabled: Bool) -> some View {
rotation3DEffect(.degrees(enabled ? 180 : 0), axis: (x: 0, y: 1, z: 0))
}
}
これは関数名から作用が想像しやすいですし、何より今後同じ処理を行いたい時に再利用できます。
最終的なコードは以下のようになりました。
/// スタンプ用のバルーン
struct ChatStampBaloon: View {
var stamp: Stamp
var edge: AllowEdge
var body: some View {
ChatBaloonShape(cornerRadius: 16, triangleSize: 8)
.fill(Color.gray) // 灰色で塗りつぶし
.frame(width: 200, height: 173)
.horizontalFlipped(edge == .trailing) // 反転
.overlay {
if let url = URL(string: stamp.url) {
URLImage(...) // スタンプ画像をオーバーレイで表示
}
}
}
enum AllowEdge {
case leading
case trailing
}
}
先ほど作成した ChatBaloonShape
を灰色で塗りつぶし、 edge
の値に応じて反転を行い、その上にスタンプ画像を overlay
で表示しています。
ちなみに画像を非同期で取得する処理には URLImage という OSS を利用しています。
カウシェでは iOS 14+ をサポートしているために OSS を利用していますが、iOS 15+ であれば標準の AsyncImage を利用するのがよい選択かもしれません。
スタンプ投稿 View(自分)
さて、ここまでくれば自分と他の人のスタンプ投稿の View を作成するのは簡単です。
よりシンプルな「自分」の View から作成してみます。
コードは以下のようになります。
/// スタンプ投稿(自分)
struct ChatBaloonMy: View {
var stamp: Stamp
var createTime: Date
var body: some View {
HStack(alignment: .bottom) {
// タイムスタンプ
timestamp(date: createTime)
.multilineTextAlignment(.trailing)
// 吹き出し
ChatStampBaloon(stamp: stamp, edge: .trailing)
}
}
}
HStack(alignment: .bottom)
でタイムスタンプと吹き出しを配置するというシンプルなコードです。
タイムスタンプ部分は共通で使いたくなるはずなので、同一ソース内に以下のような関数を定義しました。
private func timestamp(date: Date) -> some View {
Text(
"""
\(date.Md_JP())
\(date.Hmm())
"""
)
.font(.caption)
.foregroundColor(Color.gray)
}
なお、SwiftUI の文脈からは外れますが、可読性を損なわないように日付のフォーマットは extension で定義しています。
// swiftlint:disable identifier_name
extension Date {
/// `H:mm`形式でフォーマット
/// - Returns: 結果
func Hmm() -> String {
DateFormatter.Hmm.string(from: self)
}
/// `M月d日`形式でフォーマット
/// - Returns: 結果
func Md_JP() -> String {
DateFormatter.Md_JP.string(from: self)
}
}
extension DateFormatter {
/// `H:mm`形式
static let Hmm: DateFormatter = create("H:mm")
/// `M月d日`形式
static let Md_JP: DateFormatter = create("M月d日")
}
private func create(_ format: String) -> DateFormatter {
let formatter = DateFormatter()
formatter.dateFormat = format
return formatter
}
ちなみに Hmm
や Md_JP
といった識別子は Swift において一般的ではなく、SwiftLint のデフォルトのルール設定では弾かれるようになっています。
しかし、ここでは可読性を優先して意図的にルールから除外しています。
スタンプ投稿 View(他の人)
次に「他の人」の View を作成してみます。
といっても、こちらもプロフィールのアイコン・ニックネームが追加されているのを除けば一緒なので、少しレイアウトが複雑になる程度です。
struct ChatBaloonOther: View {
var stamp: Stamp
var profile: UserProfile
var createTime: Date
var body: some View {
HStack(alignment: .top, spacing: 8) {
// プロフィールアイコン
NavigationLink {
FollowProfileView(...)
} label: {
ProfileIconView(url: URL(string: profile.icon.url), size: 40)
}
VStack(alignment: .leading, spacing: 2) {
// ニックネーム
Text(profile.nickName)
.font(.caption)
.foregroundColor(Color.gray)
.padding(.leading, 8)
HStack(alignment: .bottom) {
// 吹き出し
ChatStampBaloon(stamp: stamp, edge: .leading)
// タイムスタンプ
timestamp(date: createTime)
.multilineTextAlignment(.leading)
}
}
}
}
}
プロフィールアイコンは、すでに共通部品として作成されていた ProfileIconView
を利用しています。このように共通 View を簡単に作成・再利用できるのは SwiftUI の利点と言えます。
さて、これで「自分」と「他の人」の両方の View の作成が終わったことになります。
カウシェでは基本的に1ソースに1 Viewという構成になっていますが、これらの View は関連性が非常に高く、Preview も一緒に見たいと思ったので1ソース内にまとめています。(将来的に変更・調整する際も便利でしょう)
import KaucheMockKit // モック用のデータ定義
struct ChatBaloonOther_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: 16) {
// 自分
ChatBaloonMy(
stamp: .init(number: 1),
createTime: timestamp("2022/9/4 1:23")
)
.frame(maxWidth: .infinity, alignment: .trailing)
// 相手
ChatBaloonOther(
stamp: .init(number: 2),
profile: .init(...),
createTime: timestamp("2022/11/14 11:23")
)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding()
}
static func timestamp(_ dateString: String) -> Date {
DateFormatter.yyyyMMdd_HHmm.date(from: dateString)!
}
}
KaucheMockKit
というモジュールを import していますが、これはモック用のデータが定義されたモジュールで、今回のような Preview やテストコードにおいて活用されています。
今回はそこにデータを定義したおかげで stamp: .init(number: 1)
のようにノイズの少ない記述になっています。
その他のセル
さて、これでチャット用の View が揃ったわけですが、一覧にはシステムメッセージ・システムスタンプなども表示される仕様になっています。
先に Preview を見ていただくのが分かりやすいでしょう。
これらを ScrollView
内で分岐するのは見通しが悪くなると考え、以下のように1行に対応する View を作成しました。
struct GroupChatRow: View {
var message: GroupMessage
var body: some View {
switch message.message {
case let .systemMessage(message):
...
case let .systemStamp(stamp):
...
case let .myStamp(stamp):
ChatBaloonMy(stamp: stamp, createTime: message.createTime)
.frame(maxWidth: .infinity, alignment: .trailing)
case let .otherUserStamp(stamp, profile):
ChatBaloonOther(stamp: stamp, profile: profile, createTime: message.createTime)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
コード内容はシンプルですが、さきほどのスクリーンショットのように Preview でパターンを網羅することで、全体の表示バランスなどを確認しやすいメリットもあります。(チーム内で本記事をレビューいただいたところ、どうやら Android においても同様の意思決定がされていたようです)
チャット画面
それではいよいよチャット画面を作成したいと思います。
最初に今回の要件を整理したいと思います。
- 下から上に向かって「新しい → 古い」というデータの並び順
- 上までスクロールしたら追加読込(ページング)
- 下部に固定されたボタンで「スタンプ選択用のシート」が立ち上がる
- そこでスタンプが選択されるとシートが閉じ、末端にデータが追加・スクロールされる
チャットアプリによくある「自動更新(新着)」や「入力中…」といった機能はありませんが、それでもそれなりに込み入っています。
1つずつ順に見ていきたいと思います。
一覧データを表示する
これはさほど難しくありません。
画面表示時に API を叩いて必要なデータを取得し、それを ScrollView
+ LazyVStack
に流し込むだけで大丈夫なはずです。
1つ注意点があるとすれば、他のチャットアプリに(Slack で言えば「チャンネル」)といった独立した会話スペースがあるように、カウシェでは「シェア買いのグループ」を独立した会話スペースにする必要がある点です。
なので、画面を表示する際には「グループ ID」を渡すインターフェースになります。
カウシェでは MVVM アーキテクチャを採用しており、実際の通信は ViewModel を行うことになるため、イニシャライザは以下のような形になります。
/// グループチャット画面
struct GroupChatView: View {
@StateObject private var viewModel: GroupChatViewModel
/// 初期化。
/// - Parameter groupId: グループID
init(groupId: String) {
_viewModel = .init(wrappedValue: .init(groupId: groupId))
}
}
一時期話題になりましたが、 @StateObject
のドキュメントには Initializer を直接呼び出してはいけないという記載があるものの、WWDC 21 のデジタルラウンジにて「初期化のタイミングで一度だけ呼び出すのは問題ない」という回答がなされています。
ところで、実際に通信する時は「読み込み中のインジケータ」を表示したり、通信に失敗したら「リトライするための画面」を表示する必要があります。
これは前後する形になってしまうのですが、今回の開発で LoadingContentView
という共通 View を作成しており、ViewModel も含めると以下のような形になっています。
var body: some View {
LoadingContentView(fetcher: viewModel.onInit) { data in
// 読込が成功した時に表示するコンテンツ
}
}
@MainActor
final class GroupChatViewModel: ObservableObject {
private let useCase: GroupChatUseCase
/// メッセージ一覧
@Published var messages: [GroupMessage] = []
/// 次のページトークン
@Published var nextPageToken: String = ""
/// 初期化。
/// - Parameter groupId: グループID
init(groupId: String) {
self.groupId = groupId
self.useCase = .init(groupId: groupId)
}
/// 初期読込
func onInit() async throws {
let response = try await useCase.fetchMessages(pageToken: "")
messages = response.messages
nextPageToken = response.nextPageToken
}
}
カウシェではすでに Swift Concurrency を導入しており、新規で開発する機能については Combine
ではなく async/await
を利用しています。
LoadingContentView
に渡された viewModel.onInit
によって初期読込が発火し、画面に表示するデータ一覧である messages
という Published
プロパティに API から取得されたデータを格納しています。
なお説明は割愛しますが、 LoadingContentView
は以下のようにして実装されています。
/// 通信中・エラー・再読み込みを備えた View
struct LoadingContentView<Content: View, Data>: View {
@StateObject private var viewModel: LoadingContentViewModel<Data>
@ViewBuilder private var content: (Data) -> Content
init(
fetcher: @escaping () async throws -> Data,
content: @escaping (Data) -> Content
) {
_viewModel = .init(wrappedValue: .init(fetcher: fetcher))
self.content = content
}
var body: some View {
switch viewModel.state {
case .loading:
AppLoadingView()
case .error:
ErrorView(...)
case let .loaded(data):
content(data)
}
}
}
そして、対応する LoadingContentViewModel
は以下のように実装されています。
@MainActor
final class LoadingContentViewModel<Data>: ObservableObject {
typealias Fetcher = () async throws -> Data
@Published var state: State = .loading
private let fetcher: Fetcher
init(fetcher: @escaping Fetcher) {
self.fetcher = fetcher
Task {
await reload()
}
}
}
// MARK: Data
extension LoadingContentViewModel {
enum State {
case loading
case error
case loaded(Data)
}
}
// MARK: Event
...
さて、最後は View 側で messages
を参照して、データが表示すればよいはずです。
ScrollView {
LazyVStack(spacing: 16) {
// 😀 スタンプ一覧
ForEach(viewModel.messages) { message in
GroupChatRow(message: message)
}
}
.padding(.horizontal, 16)
}
ここで、先ほど作成した1行用の GroupChatRow
を利用しています。
ところで、このコードでは List
ではなく ScrollView
+ LazyVStack
という構成を利用していますが、これはカウシェが iOS 14+ をサポートしており、その場合に List
の罫線の制御が面倒になるためです。
追加読込における課題
続いて追加読込です。
これは他画面でも実装済みのもので、大雑把な実装としては末尾のセルが onAppear
されたタイミングで追加読込をコールといった方法で行えるので一見すると簡単そうです。(厳密に考えだすともっと複雑なのですが)
しかし、ここでは他画面と異なり 一番上までスクロールされたら追加読込 という仕様であるため、スムーズな UI を実現しようと一筋縄には行かないことが分かります。
追加で取得したデータを messages
プロパティに追加すると、その分のデータが一気に画面に表示され、急に画面がスクロールされたように見えてしまいます。
これは ScrollView
の offset
を考えると分かりやすいですが、例えば offset=0
にスクロールされていたとして、追加読込用のデータが先頭に追加されても ScrollView
は依然として offset=0
の状態が持続されることになり、結果として急にスクロールされたように見えてしまうわけです。
これは一見すると難問に見えますが、すでにこの記事に登場したテクニックで解決できるのですが、ピンと来る方はいるでしょうか。
追加読込の解決方法
正解は rotation3DEffect
モディファイアで、これによって ScrollView
を上下反転させてしまうという方法が使えます。そうすれば他の画面と同様、 ScrollView
の上端ではなく下端で追加読込を行うというロジックをそのまま利用できることになります。
もちろんそのままでは中身の View も上下反転してしまうので、そちらも同様に上限反転させ、全体として辻褄を合わせるようにします。
前回、Y軸で反転させる拡張関数を作成したので、今回はX軸で反転させる拡張関数を作成できます。
extension View {
/// X軸で反転させる。
/// - Parameter enabled: `true`なら反転。`false`ならそのまま。(デフォルトは`true`)
/// - Returns: 結果
func verticalFlipped(_ enabled: Bool = true) -> some View {
rotation3DEffect(.degrees(enabled ? 180 : 0), axis: (x: 1, y: 0, z: 0))
}
}
このままでも良いですが、せっかくなので SwiftUI らしい統合された API にリファクタリングしてみます。
/// 水平方向または上下方向に反転させる。
/// - Parameters:
/// - axis: 反転方向
/// - enabled: 反転を有効にするかどうか。
/// - Returns: 結果
@ViewBuilder
func flipped(_ axis: Axis, enabled: Bool = true) -> some View {
switch axis {
case .vertical:
rotation3DEffect(.degrees(enabled ? 180 : 0), axis: (x: 1, y: 0, z: 0))
case .horizontal:
rotation3DEffect(.degrees(enabled ? 180 : 0), axis: (x: 0, y: 1, z: 0))
}
}
これによって flipped(.vertical)
といった直感的で利用しやすい API になりました。
実際に利用したコードは以下のようになります。
ScrollView {
LazyVStack(spacing: 16) {
// 😀 スタンプ一覧
ForEach(viewModel.messages) { message in
GroupChatRow(message: message)
.flipped(.vertical) // セルを上下反転
}
}
.padding(.horizontal, 16)
}
.flipped(.vertical) // ScrollView を上下反転
ScrollView
を上下反転させたら、ジェスチャも反転してしまうのではないかと予想される方もいるかもしれませんが、そのあたりは賢く処理されておりジェスチャも自動的に補正されます。
ちなみに Z 軸で回転させる rotationEffect
というモディファイアも存在します。
わざわざ rotation3DEffect
を利用せずとも、こちらで180°回転させれば良いと感じるかもしれませんが、 ScrollView
には右端にインジケータ表示があるため、 rotationEffect
で回転させた場合にはインジケータが左側に移動してしまうという問題があります。
さて、あとは実際の追加読込です。
先述したように特定のセルでの onAppear
で発火しても良いのですが、今回は GeometryReader
+ PreferenceKey
を利用して スクロール位置を検知する 方法を採用してみました。
これは、その方が追加読込のタイミングを調整しやすく、(バックエンドの負荷と相談しながら)UI/UX の改善が容易になると判断したためです。
View 側のコードは以下のようになります。
ScrollView {
LazyVStack(spacing: 16) {
// 😀 スタンプ一覧
ForEach(viewModel.messages) { message in
GroupChatRow(message: message)
.flipped(.vertical)
}
// 🔄 追加読込インジケータ
if viewModel.nextPageToken.isNotEmpty {
LoadingIndicatorCell()
}
// 📐 スクロール検知
GeometryReader { geometry in
Space(height: 0)
.preference(
key: OffsetPreferenceKey.self,
value: geometry.frame(in: .global).origin.y
)
}
}
}
.flipped(.vertical)
.onPreferenceChange(OffsetPreferenceKey.self) { offset in
// Note:
// さらに早い段階で先読みしたい場合は数値を小さくしてもよい。(サーバ負荷は考慮のこと)
if 0 < offset {
Task {
await viewModel.onLoadMore()
}
}
}
GeometryReader
内に定義された Space(height: 0)
は利便性のために追加している独自の View で、 Spacer().frame(height: 0)
のシンタックスシュガーとして利用しています。
public struct Space: View {
private var width: CGFloat?
private var height: CGFloat?
public init(width: CGFloat) {
self.width = width
self.height = nil
}
public init(height: CGFloat) {
self.width = nil
self.height = height
}
public var body: some View {
if let width {
Spacer().frame(width: width)
} else if let height {
Spacer().frame(height: height)
}
}
}
ちなみにこれは Android プロジェクトにおいて同様の手法が行われており、それを iOS プロジェクトに輸入した形になります。
話を戻しますと、このダミーの View の位置を GeometryReader
で計測してその Y 座標を preference
を利用して上位 View に報告し、 onPreferenceChange
で変化を観測し、特定の Y 座標を越したら追加読込を発火する、という作りになっています。
ここでは大雑把に .global
を指定してグローバルフレームからの相対位置を取得していますが、より正確に計測したい場合には専用の名前空間を利用したほうが良いでしょう。
スタンプ選択
さて、次にスタンプ選択画面に着手します。
直近の選択があれば「最近使ったスタンプ」に表示され、その下は「利用可能なすべてのスタンプ」という画面構成になっています。
多くの方が想像されるであろうとおり、ここでは iOS 14+ から利用可能な LazyVGrid を利用してスタンプのグリッド表示をしています。
使用履歴と一覧の両方で共通に利用したいため、ここでは以下のように共通関数を定義しています。(独立した View として定義するのも1つの方法でしょう)
func stampsGrid(stamps: [Stamp]) -> some View {
LazyVGrid(columns: Array(repeating: GridItem(), count: 4)) {
ForEach(stamps) { stamp in
StampView(stamp: stamp)
.onTapGesture {
Task {
await viewModel.onTapStamp(stamp: stamp)
dismiss()
}
}
}
}
}
onTapGesture
でタップ処理を行っていますが、これについては後述したいと思います。
さて、中身のセルとして利用している StampView
は以下のようにシンプルな View になっています。
/// スタンプ一覧のスタンプ
struct StampView: View {
var stamp: Stamp
var body: some View {
if let url = URL(string: stamp.url) {
URLImage(
url,
inProgress: { _ in Color.clear },
failure: { _, _ in Color.clear },
content: { content in
content
.resizable()
.scaledToFit()
}
)
.padding(2)
.background {
Color.gray.clipShape(RoundedRectangle(cornerRadius: 16))
}
}
}
}
ここまできて、なぜこんな単純なコードを紹介したのか疑問に思う方も多いでしょう。
実はこのコードのとある部分が現在の実装とは異なっていたのですが、(おそらく)それが原因となり iOS 14 限定で非常に奇妙なバグに遭遇していました。
私の zp チャンネルに発言が残っていたのでスクショを貼りたいと思います。
今となっても正確な原因は掴んでいないのですが、どうやら URLImage
の inProgress
の部分で EmptyView
を利用していたのがマズかったらしく、代わりに Color.clear
に変更することで、この奇妙な不具合は一切再現しなくなりました。
これは予想でしかありませんが、iOS 14 における LazyVGrid
のバグなのではないか、と疑っています。
本当の原因がどこにあったかはさておき、SwiftUI を利用した開発ではこうした奇妙なバグに遭遇することが稀にあり、そうした際には UIKit 時代より調査が難航する傾向にある と感じています。
SwiftUI の生産性は高いと感じていますが、こうした不具合に遭遇したときの対処工数が全く読めないという点は、現時点の評価として心に留めておく必要があると感じます。
スタンプ投稿
さて、先ほど後述するとした「スタンプがタップされた時の処理」ですが、具体的には以下のような処理を行っています。
- サーバ側に選択されたスタンプを送信
- 表示中の自分のシートを閉じる
- 一覧に選択されたスタンプを追加する
まずは、ViewModel 側のコードを見てみたいと思います。
/// スタンプ一覧画面
@MainActor
final class StampListViewModel: ObservableObject {
/// スタンプ投稿完了時のコールバック
private var onPostedStamp: (Stamp?) -> Void
/// スタンプタップ時
func onTapStamp(stamp: Stamp) async {
do {
// 最近使ったスタンプを更新
// (投稿に失敗した際にやり直しやすいように先に更新しておく)
updateRecentSampts(stamp: stamp)
// スタンプ投稿
try await groupChatUseCase.postStamp(stampId: stamp.id)
// フィードバック
let generator = UINotificationFeedbackGenerator()
generator.prepare()
generator.notificationOccurred(.success)
// コールバック(成功)
onPostedStamp(stamp)
} catch {
// コールバック(失敗)
onPostedStamp(nil)
}
}
}
まず、最近使ったスタンプ(使用履歴)を先に更新しています。
これは UserDefaults に保持されるようになっているもので、サーバ側への送信に失敗した際に、利用者がストレスを最小限にやり直しできるようにする、という意図によるものです。(余談ですがこの仕様を提案したのは私です)
次にスタンプの送信に成功した場合には触覚フィードバックで成功を通知し、初期化時に渡されたコールバックを呼び出して結果を通知するようにしています。(厳密な話をすると UINotificationFeedbackGenerator.prepare
はこの関数の先頭で呼び出したほうがベターかもしれません)
次にシートを閉じる処理ですが、これは先ほどの View 側のコードに記述されています。
StampView(stamp: stamp)
.onTapGesture {
Task {
await viewModel.onTapStamp(stamp: stamp)
dismiss() // シートを閉じる
}
}
厳密な MVVM を考えると、こうした処理は ViewModel 側の onTapStamp
で完結するべきかもしれません。
しかし、そのためにはコールバックを引き渡す必要がありますし、当然ですがその分コード量は増えてしまいます。そして、コード量が増えればバグの可能性が増えますし、コードレビューのコストも上がってしまいます。
そういった点をトレードオフとして考慮し、厳密さよりシンプルさを意識して View 側でコードを記述しています。
ところで、鋭い方は dismiss()
の部分に注目されたかもしれません。
シートを閉じるための Environment として dismiss が用意されていますが、これが利用可能なのは iOS 15+ からであり、iOS 14+ をサポートするカウシェでは利用できない API です。
実はこれは単に dismiss
というヘルパー関数を用意しているだけです。
func dismiss() {
presentationMode.wrappedValue.dismiss()
}
これは一見過剰な実装に見えるかもしれませんが、iOS 15+ の API に差し替えた際にコード修正が最小限で済むというメリットがあると予想しています。
スタンプ投稿後の処理
さて、最後にスタンプ一覧 View をシート表示している部分を見てみましょう。
/// グループチャット画面
struct GroupChatView: View {
@StateObject private var viewModel: GroupChatViewModel
/// スタンプ一覧のシートを表示しているか
@State private var isPresentedStampSheet: Bool = false
var body: some View {
LoadingContentView(fetcher: viewModel.onInit) { _ in
ScrollViewReader { proxy in
content()
.onChange(of: viewModel.scrollToBottom) { value in
if value != nil {
withAnimation {
proxy.scrollTo("bottom")
}
}
}
}
.overlay(alignment: .bottom, content: footer)
.snackBar($viewModel.snackBarContent)
.sheet(isPresented: $isPresentedStampSheet) {
// スタンプ選択画面
StampListView(
groupId: viewModel.groupId,
onPostedStamp: { stamp in
Task {
await viewModel.onPostedStamp(stamp)
}
}
)
}
}
}
}
これは先述したチャット画面のコードで、途中で紹介した LoadingContentView
および ScrollViewReader
に囲まれた content()
がここまで解説してきたチャット画面の中身の実装となっています。
まず、スタンプ画面をシート表示している箇所を見てみましょう。
.sheet(isPresented: $isPresentedStampSheet) {
// スタンプ選択画面
StampListView(
groupId: viewModel.groupId,
onPostedStamp: { stamp in
Task {
await viewModel.onPostedStamp(stamp)
}
}
)
}
シート周りは標準的な使い方なので、特筆すべき点はないかと思います。
onPostedStamp
のクロージャの部分が、前項で触れたコールバックの部分で、成功した場合は選択されたスタンプ、失敗した場合には nil
が引数として渡されます。
ViewModel 側のコードは以下のようになっています。
@MainActor
final class GroupChatViewModel: ObservableObject {
/// 末端にスクロールするトリガー
@Published var scrollToBottom: UUID?
/// スタンプ投稿のコールバック
func onPostedStamp(_ stamp: Stamp?) async {
if stamp == nil {
snackBarContent(L10n.Stamp.SnackBar.sendFailed)
} else {
do {
// 最新のメッセージ一覧を取得して追加された分をマージ(ページトークンは更新しない)
let response = try await useCase.fetchMessages(pageToken: "")
withAnimation {
messages = messages.mergeLatest(response.messages)
}
// 末端までスクロール
scrollToBottom = .init()
} catch {
snackBarContent(L10n.Stamp.SnackBar.reloadFailed)
}
}
}
}
成功時には一覧の1ページ目を再取得し、その結果を一覧にマージするようにしています。
これにより利用者が選択したスタンプ、およびそれまでに他の人が送信したスタンプが画面に追加されることになります。
そして最後に末端まで自動スクロールするようにしています。
これは UUID
型で宣言されたトリガー用の変数を更新することで、それが View 側の onChange
で補足され、 ScrollViewReaderProxy
の scrollTo()
を呼び出すという構造になっています。
ScrollViewReader { proxy in
content()
.onChange(of: viewModel.scrollToBottom) { value in
if value != nil {
withAnimation {
proxy.scrollTo("bottom") // 末端までスクロール
}
}
}
}
こうした UUID
と onChange(of:)
ではなく、 PassthroughSubject
と onReceive
を利用することでも同等のことが可能です。
しかし、以前に後者の実装パターンを利用した際に、特定の条件が重なると正しく動かないケースがあったため、私はこのコーディングパターンで落ち着いています。( UUID
だと用途が広いので Trigger
などの専用の型やモディファイアを用意するというのも1つの改善として考えられます)
スナックバーの表示
さて、ここまでに殆どの機能の主要なコードを見てきました。
さすがにこの記事も長くなりすぎたので、最後に通信エラーが発生した際の「スナックバー表示」の実装について解説して終わりにしたいと思います。
「スナックバー」とは Android 用語であり、正確に機能をコピーしたものではなく、現状のカウシェの iOS アプリでは画面下部に表示するトーストのようなものになっています。
まず、View 側については専用のモディファイアを用意し、 alert
などと同じような感覚で使用できるようにしています。
.snackBar($viewModel.snackBarContent)
ViewModel 側の Published
プロパティは、専用の型である SnackBarContent
を用意し、それに表示したいメッセージを引数に与えて呼び出すことでスナックバーを表示しています。
@MainActor
final class GroupChatViewModel: ObservableObject {
/// スナックバー
@Published var snackBarContent: SnackBarContent = .init()
/// スタンプ投稿のコールバック
func onPostedStamp(_ stamp: Stamp?) async {
if stamp == nil {
snackBarContent(L10n.Stamp.SnackBar.sendFailed) // スナックバー表示
} else {
...
}
}
}
鋭い方はお気づきかもしれませんが、これは Swift の callAsFuction
という機能を利用しています。
/// スナックバー表示用のコンテンツ
public struct SnackBarContent {
public var isPresented: Bool = false
public var message: String = "" {
didSet {
isPresented = true
}
}
public init() {}
/// 指定されたメッセージでスナックバーを表示
/// - Parameter message: メッセージ
public mutating func callAsFunction(_ message: String) {
self.message = message
}
}
状態としては、表示中かどうかを表す isPresented
と、表示中のメッセージである message
の2つを保持しています。
callAsFunction
経由でコールされた際には message
を更新し、 message
の didSet
で isPresented
を true
に更新することで、スナックバーを表示状態にするという仕組みにしています。
これによって snackBarContent.message = "xxx"
ではなく snackBarContent("xxx")
という簡潔で可読性の高い API になっています。(私は好みが分かれると思ったのですが、PR ではチームメンバーから好意的な意見を多く頂いたので採用する形になりました)
ところで、一見すると Single Source of Truth の考え方からすると message
を String?
で宣言すれば、 nil
を非表示状態として扱えるため isPresented
を削除できるように感じます。
しかし、スナックバーはフェードアウトしながら消えるため、そのタイミングで message
を nil
に更新すると、そのタイミングでテキストが消えてしまうため意図したアニメーションになりません。
関数を共通化する際は、それが本当に共通のものなのか、あるいは似て非なるものなのかを考えることが大切とはよく言われますが、SwiftUI においてもそれが本当に 唯一のデータソースとしてまとめられるかどうか を一考する場面もあります。
最近、アプリのパフォーマンス改善にも取り組んでいる際にもこうしたケースはあり、SwiftUI だからといって必ずしも(単純な意味での) Single Source of Truth が唯一の正解でないことは、軽く心に留めておくとよいかもしれません。
まとめ
さて、そんなわけで「グループスタンプ機能」について、どのように開発・実装がされたかを書かせていただきましたが、参考になる部分はあったでしょうか。
SwiftUI はある程度学習しても、実際に組み合わせて使おうとすると難しいことがあり、「実際のプロダクトで開発・リリースされた機能について、1から解説する」ような記事は需要があるのではないかと感じ、今回はこのような構成の記事にチャレンジさせていただきました。
もし需要があれば定期的にこうした形で技術共有していきたいという話も出ていますので、よろしければ「いいね」や 👏 などいただけますと幸いです。(おそらく一定数いけば、次回も記事を執筆する機会が得られると思います(笑)
タイトルにもあるように、iOS 側の機能は5日間で開発されました。
デザイナフィードバックや仕様変更、PR レビューも含め、QAが開始できるまでにかかった総日数(私は週2日稼働なので3分割されています)で、この記事で触れなかった機能・APIとのつなぎ込み・イベントログなどの実装も含まれています。
さらに、 QA でのバグは0件でした。
私は Twitter で SwiftUI の生産性の高さを評価するツイートを度々していますが、この記事で触れているように共通部品の実装や予期せぬ SwiftUI のバグも含めてこの日数ですから、やはり SwiftUI の生産性は高いと言わざるを得ません。(そして SwiftUI がさらに強力なのは、今後の仕様変更やリファクタリングでもあります)
私は普段、技術記事でこういった生産性については取り上げないの主義なのですが、SwiftUI での生産性についての実例があまり表に出ていないと感じ、今回は例外的に取り上げさせていただきました。
仲間を募集しています
多くのスタートアップのプロダクトがそうであるように、外から見るとすでに完成されたプロダクトに見えても、実際には まだまだやることは山のようにある というのはよくある話です。
「カウシェ」もその例に漏れず、今後の開発・改善スケジュールはぎっしりと詰まっています。
今回ご紹介させていただいたグループスタンプ機能もコミュニケーションの入り口でしかありませんし、よりお客様にあった商品を適切にレコメンドしたり、検索性を改善したり、あるいはシェア買いという軸以外から楽しいショッピング体験というものを切り開いていく必要もあるでしょう。
少なくとも私にとって、カウシェは 最高の仕事場の1つ です。
前述したビジョンに加え、
- Try First
- For Team
- Enjoy Working
という3つのValueも掲げており、それを土台にして最高の仲間達が最高のプロダクト・サービスを作ることに全力を捧げています。
エンジニアリングとして Try First の文化がよく機能しており、iOSアプリの技術的な面を見ても「SwiftUI」や「Concurrency」などをいち早く導入され、日々の生産性を確実に向上させる努力を怠っていません。(これは Android アプリチームにおいても同様です)
使い古された一文ではありますが…
カウシェではビジョンに共感できる仲間を募集しています。
現在はかなり多岐にわたった職種が募集されていますので、興味が出たら気軽にカジュアル面談などをしていただくのも良いかと思います。(優しい方ばかりです)
さて、あまり長くなると締まらないので、今回はこのあたりで終わりにしたいと思います。
それでは、引き続き「カウシェ」でのショッピング体験をお楽しみくださいませ。
Merry Christmas, all shopping lovers.