Slide 1

Slide 1 text

SwiftUI Transactionを徹底活用 ZOZOTOWN UI開発での活用事例 株式会社ZOZO
 ZOZOTOWN開発本部 ZOZOTOWN開発2部 iOSブロック
 續橋 涼 Copyright © ZOZO, Inc. 1 Extended Tokyo - WWDC 2025

Slide 2

Slide 2 text

© ZOZO, Inc. 株式会社ZOZO ZOZOTOWN開発本部 ZOZOTOWN開発2部 iOSブロック 續橋 涼 2019.4: ヤフー 2023.2: DeNA 2024.8: ZOZO - 個人開発が趣味 - 30個以上App Storeリリース 2 好きなマックスくん

Slide 3

Slide 3 text

© ZOZO, Inc. https://zozo.jp/ 3 ● ファッションEC ● 1,600以上のショップ、9,000以上のブランドの取り扱い ● 常時107万点以上の商品アイテム数と毎日平均2,700点以上の新着 商品を掲載(2025年3月末時点) ● ブランド古着のファッションゾーン「ZOZOUSED」や コスメ専門モール「ZOZOCOSME」、シューズ専門ゾーン 「ZOZOSHOES」、ラグジュアリー&デザイナーズゾーン 「ZOZOVILLA」を展開 ● 即日配送サービス ● ギフトラッピングサービス ● ツケ払い など

Slide 4

Slide 4 text

© ZOZO, Inc. 4 メイク投稿コンテンツのZOZOTOWN連携の背景 ZOZOTOWNの特徴 「商品」軸のECサイト ファッションアイテムを販売 WEARの特徴 「人」軸のコンテンツプラットフォーム ユーザーが自身のコーデやメイクを投稿 目的 ZOZOTOWNユーザーが商品探しの参考にメイク投稿を活用できるようにすること 対象 ベースメイク・メイクアップ関連カテゴリーに限定 x 連携条件 特定のユーザー種別( WEARISTA/著名人、ショップスタッフ等)の投稿のみ 一部ショップ限定

Slide 5

Slide 5 text

© ZOZO, Inc. 5 既存コーデ投稿表示機能 技術負債とユーザー体験の課題 UIKit & Combineの実装 WEARコーデ投稿機能はほぼUIKitとCombineで実装 今のZOZOTOWNの採用アーキテクチャと異なっていた モーダル画面の問題 下スワイプで閉じられない ユーザーストレス 閉じることが容易にできないと、開くこともしなくなる

Slide 6

Slide 6 text

© ZOZO, Inc. SwiftUI導入の動機と新たな挑戦 リッチなユーザー体験 自然なアニメーションの実現 標準アーキテクチャ MVVM+UseCase テスタブルな実装 UnitTestを書きやすい設計 ユーザー体験へのこだわり: ・下スワイプで閉じられる自然な操作感 ・画像拡大時に元の場所からスムーズに拡大し、閉じる際も元の場所へ戻る 6

Slide 7

Slide 7 text

© ZOZO, Inc. 7 具体的なUI課題 モーダル上のフルスクリーンモーダル メイク投稿詳細画面自体が既にpresentされたモーダル画面 全画面表示の要件 セーフエリアまで覆われた全画面の画像Viewerが必要 標準APIの限界 デフォルトアニメーションでは滑らかな拡大を表現できない

Slide 8

Slide 8 text

© ZOZO, Inc. 8 実装途中の遷移アニメーション やりたいこと ● 下から上に行くアニメーション無くす ● 画像を元の位置から動くように表示させる ● 表示領域が変わる分のSafeAreaInsetsの計算する ● Viewerの中で画面を閉じるときは逆の動きを行う

Slide 9

Slide 9 text

© ZOZO, Inc. 9 SwiftUI Transaction の活用 デフォルト無効化 標準トランジションを一時的に無 効化し、カスタムアニメーション を優先 細かい制御 モーダル遷移のライフサイクルと 画像アニメーションを同期・制御 する Transaction SwiftUIのビュー更新とアニメー ションの「コンテキスト」を伝播 ・制御する仕組み 明示的な制御 複雑な要素が絡むアニメーション に、明示的な制御を行う

Slide 10

Slide 10 text

© ZOZO, Inc. 10 実装の詳細:Transactionによる制御 画像タップ時の処理 Transactionを作成し、disablesAnimations = trueに設定 fullScreenCoverの標準アニメーションを無効化 画像自身のアニメーション SafeAreaInsetsを考慮した移動アニメーションを行う 画像クローズ時の処理 拡大画面を閉じるときでもTransactionを使用 Viewerが非表示になる際の標準アニメーションを無効化

Slide 11

Slide 11 text

© ZOZO, Inc. 11 画像タップ時の処理 .onTapGesture { var transaction = Transaction() // Transactionの作成 transaction.disablesAnimations = true // 遷移アニメーションの無効化 withTransaction(transaction) { // 遷移時の処理 showImageDetail.toggle() // 画像Viewerを表示 } }

Slide 12

Slide 12 text

© ZOZO, Inc. 12 実装の詳細:Transactionによる制御 画像タップ時の処理 Transactionを作成し、disablesAnimations = trueに設定 fullScreenCoverの標準アニメーションを無効化 画像自身のアニメーション SafeAreaInsetsを考慮した移動アニメーションを行う 画像クローズ時の処理 拡大画面を閉じるときでもTransactionを使用 Viewerが非表示になる際の標準アニメーションを無効化

Slide 13

Slide 13 text

© ZOZO, Inc. 13 画像自身のアニメーション @Binding var showImageDetail: Bool @Binding var transform: CGAffineTransform // 常に位置、大きさなどの情報を同期させておく @Binding var bottomInsets: CGFloat // FullScreenModalになることによるSafeAreaの考慮 . . image .scaleEffect( x: transform.scaleX, // extensionで作ってる y: transform.scaleY, anchor: .zero ) .offset(x: transform.tx, y: transform.ty) .offset(y: -bottomInsets) .opacity(showImageDetail ? 1 : 0)

Slide 14

Slide 14 text

© ZOZO, Inc. 14 CGAffineTransform の extension extension CGAffineTransform { static func anchoredScale(scale: CGFloat, anchor: CGPoint) -> CGAffineTransform { .init(translationX: anchor.x, y: anchor.y) .scaledBy(x: scale, y: scale) .translatedBy(x: -anchor.x, y: -anchor.y) } var scaleX: CGFloat { sqrt(a * a + c * c) } var scaleY: CGFloat { sqrt(b * b + d * d) } }

Slide 15

Slide 15 text

© ZOZO, Inc. 15 実装の詳細:Transactionによる制御 画像タップ時の処理 Transactionを作成し、disablesAnimations = trueに設定 fullScreenCoverの標準アニメーションを無効化 画像自身のアニメーション SafeAreaInsetsを考慮した移動アニメーションを行う 画像クローズ時の処理 拡大画面を閉じるときでもTransactionを使用 Viewerが非表示になる際の標準アニメーションを無効化

Slide 16

Slide 16 text

© ZOZO, Inc. 16 画像クローズ時の処理 func dismissImage() async { // 0.2秒で前画面の画像の高さの位置に戻す withAnimation(.easeInOut(duration: 0.2)) { bottomInsets = -bottomInsets } try? await Task.sleep(nanoseconds: 200 * 1000 * 1000) // 0.1秒で背景色を透明にしていく withAnimation(.easeInOut(duration: 0.1)) { showAnimation = false } try? await Task.sleep(nanoseconds: 100 * 1000 * 1000) // 遷移アニメーションを無効にする var transaction = Transaction() transaction.disablesAnimations = true withTransaction(transaction) { showImageDetail = false } }

Slide 17

Slide 17 text

© ZOZO, Inc. 17 Viewerでの画像操作 複合ジェスチャー ● DragGesture 画像の平行移動 ● MagnifyGesture 表示されている位置を中心にズーム ● SpatialTapGesture 画像をタップしたとき、タップした位置にズームする

Slide 18

Slide 18 text

© ZOZO, Inc. 18 DragGestureの制御 .onChanged { value in // デフォルト倍率のとき、上下方向のスワイプで画像の大きさ、背景色を変更する if (lastTransform.scaleX == 1) && (lastTransform.scaleY == 1) { let verticalOffset = abs(adjustedTranslation.height) / contentSize.height let scale = max(0.8, 1.0 - verticalOffset * 0.7) let newTransform = lastTransform .translatedBy( x: adjustedTranslation.width / transform.scaleX, y: adjustedTranslation.height / transform.scaleY ) transform = newTransform backgroundOpacity = max(0.0, 1.0 - verticalOffset)    }

Slide 19

Slide 19 text

© ZOZO, Inc. 19 DragGestureの制御 } else { // ズームしている状態なら、普通に並行移動する transform = lastTransform.translatedBy( x: value.translation.width / transform.scaleX, y: value.translation.height / transform.scaleY ) }

Slide 20

Slide 20 text

© ZOZO, Inc. 20 DragGestureの制御 .onEnded({ value in // デフォルト倍率のとき if (lastTransform.scaleX == 1) && (lastTransform.scaleY == 1) { let verticalOffset = abs(value.translation.height) / contentSize.height // 規定値より上下にスワイプしていたら閉じる if verticalOffset > 0.3 { Task { await dismissImage() } } else { // 規定値より上下にスワイプしていないなら状態を元に戻す transform = .identity lastTransform = .identity backgroundOpacity = 1.0 } } else {

Slide 21

Slide 21 text

© ZOZO, Inc. 21 DragGestureの制御 } else { // デフォルト倍率でないなら、 onEndGesture() } }) // Gesture終了後の共通処理 private func onEndGesture() { let newTransform = limitTransform(transform) transform = newTransform lastTransform = newTransform }

Slide 22

Slide 22 text

© ZOZO, Inc. 22 MagnifyGesture MagnifyGesture() .onChanged { value in let newTransform = CGAffineTransform.anchoredScale( scale: value.magnification, anchor: .init( x: value.startAnchor.x * contentSize.width, y: value.startAnchor.y * contentSize.height ) ) transform = lastTransform.concatenating(newTransform) } .onEnded { value in onEndGesture() }

Slide 23

Slide 23 text

© ZOZO, Inc. 23 SpatialTapGesture SpatialTapGesture(count: 1) .onEnded { value in let newTransform: CGAffineTransform = if transform.isIdentity { .anchoredScale(scale: 3, anchor: value.location) } else { .identity } transform = newTransform lastTransform = newTransform }

Slide 24

Slide 24 text

© ZOZO, Inc. 24 デモ

Slide 25

Slide 25 text

© ZOZO, Inc. 25 まとめ 1. 自然なUX ユーザーにとって自然で滑らかな画像拡大・縮小体験を提供 2. 一貫性 下スワイプによるモーダルクローズも解決 一貫したUXを実現 3. モダン化 UIKit時代の負債を解消、モダンなSwiftUIアーキテクチャで堅牢なUI構築 Transactionは複雑なUIアニメーションの制御に不可欠なツール 標準APIの限界を超え、カスタムUI/UXを実現する強力な手段

Slide 26

Slide 26 text

© ZOZO, Inc. 26 今後の展望と感謝 ZOZOTOWN iOS開発の今後 ● WWDCの最新技術をキャッチアップしてSwiftUIの可能性を最大限に引き出す ● 常にユーザー体験を第一に考え、技術的挑戦を続ける ● WWDC25を楽しみましょう! ご清聴ありがとうございました!

Slide 27

Slide 27 text

No content