SwiftUI で「グループスタンプ」というチャット機能を5日間で作った話

トビ(tobi462)
KAUCHE Tech Blog
Published in
51 min readDec 14, 2022

どうも、複業で「カウシェ」の iOS アプリ開発に参画させていただいているトビ(@tobi462 )です。

iOS テスト本の著者、あるいは最近では Effective SwiftUI の著者、という自己紹介が伝わりやすいでしょうか。

今回、複業という立場ながらアドベントカレンダーを執筆する機会をいただいたので、先日リリースされた グループスタンプ機能 について、どのように SwiftUI で開発・実装されたかを実際のコードを追いながら、順を追って詳細に解説してみたいと思います。

先日リリースされたばかりのグループチャット機能

私とカウシェ

最初に軽く私とカウシェの関わりについて触れたいと思います。(興味ないよ、という方は適当に読み飛ばしていただければと思います)

カウシェには今年の6月から複業として参画させていただいています。

カウシェでは 初期の頃から SwiftUI を採用 しており、プライベートで SwiftUI を学習していた私にとって、相性の良いと感じたのが最初のきっかけでした。

ただ、私にとって決定打になった最大の理由はそのビジョンにありました。

『世界一楽しいショッピング体験をつくる』

面談いただいた時に CPO である akifumi さんからこの言葉を聞いた時、一言でいえば 痺れました

殆ど受け売りになってしまいますが、既存の EC サービスでは効率化によって利便性が向上している一方、ウィンドウショッピングや友人との買い物を楽しむと言った、 本来のショッピングの楽しさが失われている という側面もあります。

そういった 本来のショッピングの楽しさを取り戻しつつ、同時にお得に買い物を楽しめる世界をつくる、というある意味では壮大なチャレンジに挑もうとしている のがカウシェというスタートアップです。

後に CEO である Mon さんの情熱を聞いたり、全力で最高のプロダクトを作ろうとしている人たちと一緒に仕事を進める中で、最大限貢献したいという気持ちが高まり、週の稼働日数が2日になった現在でも全力な気持ちでお仕事させていただいています。

グループスタンプ機能とは?

さて、あまり前置きが長くなっても仕方ないので、アドベントカレンダーらしく技術的な解説をしていきたいと思います。

まず、動画を見ていただくのが分かりやすいでしょう。

グループスタンプ機能の様子(開発版)

スタンプのみに限定されたチャット機能 と表現するのが近いのでしょうか。

前述したとおり「カウシェ」では楽しいショッピング体験をゴールとして掲げており、その実現に向かう1つのステップとして開発されたのが今回の グループスタンプ機能 です。

気になった商品や一緒に買いたいと思った商品について、 スタンプによる気軽なコミュニケーションで「シェア買い」というイベントを楽しんでもらう ことを目的としています。

それでは、この機能がどのような流れで開発されたか、実際のソースコードを交えながら解説していきます。(一部を改変しているコードもあります)

吹き出しの Shape

SwiftUI では 小さな部品を組み合わせて大きなものを作る という思想があるので、小さめの部品から入り、少しずつ大きなところを見ていきたいと思います。多少の前後もありますが、実際の開発の進め方もほぼ同じ手順を踏んでいます。

まず最初に目に映るのは、灰色の吹き出しです。

スタンプで他の人とコミュニケーションできる

左側が「自分以外」、右側が「自分」という立ち位置になっていますが、灰色の吹き出しはどちらも同じ形状のものが水平方向に反転しただけなので、共通のものとして実装できそうです。

このような 2D 形状を作成する道具として、SwiftUI では Shape というものが用意されています。今回は自分でカスタム形状を定義することになりますが、標準でもいくつか用意されており、とくに CircleCupsule などは利用頻度が高いのではないでしょうか。

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)
}
}
}
ChatBaloonShape のプレビュー結果

ちなみにカウシェでは 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))
rotation3DEffect を使用して水平方向に反転

これをそのまま利用しても良いですが、こうした「水平方向に反転する」という使い方は多そうですし、可読性の面からもそれが簡単に読み取れると理想的です。

そこで、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
}

ちなみに HmmMd_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)!
}
}
投稿スタンプのプレビュー(別の View だが1ソースにまとめている)

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 ではなく ScrollViewLazyVStack という構成を利用していますが、これはカウシェが iOS 14+ をサポートしており、その場合に List の罫線の制御が面倒になるためです。

追加読込における課題

続いて追加読込です。

上までスクロールされたら追加読込される

これは他画面でも実装済みのもので、大雑把な実装としては末尾のセルが onAppear されたタイミングで追加読込をコールといった方法で行えるので一見すると簡単そうです。(厳密に考えだすともっと複雑なのですが)

しかし、ここでは他画面と異なり 一番上までスクロールされたら追加読込 という仕様であるため、スムーズな UI を実現しようと一筋縄には行かないことが分かります。

追加で取得したデータを messages プロパティに追加すると、その分のデータが一気に画面に表示され、急に画面がスクロールされたように見えてしまいます。

これは ScrollViewoffset を考えると分かりやすいですが、例えば offset=0 にスクロールされていたとして、追加読込用のデータが先頭に追加されても ScrollView は依然として offset=0 の状態が持続されることになり、結果として急にスクロールされたように見えてしまうわけです。

これは一見すると難問に見えますが、すでにこの記事に登場したテクニックで解決できるのですが、ピンと来る方はいるでしょうか。

追加読込の解決方法

正解は rotation3DEffect モディファイアで、これによって ScrollView を上下反転させてしまうという方法が使えます。そうすれば他の画面と同様、 ScrollView の上端ではなく下端で追加読込を行うというロジックをそのまま利用できることになります。

もちろんそのままでは中身の View も上下反転してしまうので、そちらも同様に上限反転させ、全体として辻褄を合わせるようにします。

ScrollView のみを上下反転させた様子

前回、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 チャンネルに発言が残っていたのでスクショを貼りたいと思います。

今となっても正確な原因は掴んでいないのですが、どうやら URLImageinProgress の部分で EmptyViewを利用していたのがマズかったらしく、代わりに Color.clearに変更することで、この奇妙な不具合は一切再現しなくなりました。

これは予想でしかありませんが、iOS 14 における LazyVGrid のバグなのではないか、と疑っています。

本当の原因がどこにあったかはさておき、SwiftUI を利用した開発ではこうした奇妙なバグに遭遇することが稀にあり、そうした際には UIKit 時代より調査が難航する傾向にある と感じています。

SwiftUI の生産性は高いと感じていますが、こうした不具合に遭遇したときの対処工数が全く読めないという点は、現時点の評価として心に留めておく必要があると感じます。

スタンプ投稿

さて、先ほど後述するとした「スタンプがタップされた時の処理」ですが、具体的には以下のような処理を行っています。

  1. サーバ側に選択されたスタンプを送信
  2. 表示中の自分のシートを閉じる
  3. 一覧に選択されたスタンプを追加する

まずは、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 で補足され、 ScrollViewReaderProxyscrollTo() を呼び出すという構造になっています。

ScrollViewReader { proxy in
content()
.onChange(of: viewModel.scrollToBottom) { value in
if value != nil {
withAnimation {
proxy.scrollTo("bottom") // 末端までスクロール
}
}
}
}

こうした UUIDonChange(of:) ではなく、 PassthroughSubjectonReceive を利用することでも同等のことが可能です。

しかし、以前に後者の実装パターンを利用した際に、特定の条件が重なると正しく動かないケースがあったため、私はこのコーディングパターンで落ち着いています。( 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 を更新し、 messagedidSetisPresentedtrue に更新することで、スナックバーを表示状態にするという仕組みにしています。

これによって snackBarContent.message = "xxx" ではなく snackBarContent("xxx") という簡潔で可読性の高い API になっています。(私は好みが分かれると思ったのですが、PR ではチームメンバーから好意的な意見を多く頂いたので採用する形になりました)

ところで、一見すると Single Source of Truth の考え方からすると messageString? で宣言すれば、 nil を非表示状態として扱えるため isPresented を削除できるように感じます。

しかし、スナックバーはフェードアウトしながら消えるため、そのタイミングで messagenil に更新すると、そのタイミングでテキストが消えてしまうため意図したアニメーションになりません。

関数を共通化する際は、それが本当に共通のものなのか、あるいは似て非なるものなのかを考えることが大切とはよく言われますが、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.

--

--

トビ(tobi462)
KAUCHE Tech Blog

だいたいiOSアプリ開発者。最近は SwiftUI とか書いたり。