WWDC24のズームトランジションを実装してみる

はじめに

「iOSDC Japan 2024: iOSアプリらしさを紐解く / Natsuho Ide」の動画で、WWDCにて紹介されたアニメーションを実装してみました。 youtu.be

developer.apple.com

実装したアニメーション

実装したズームトランジション

iOS 18での新しいズームトランジション

今回紹介されていたズームトランジションは、Heroアニメーションのようなものに近いのかなと思います。 Heroアニメーションは、異なる画面間で共通するUI要素(例えば画像やテキスト)がスムーズに遷移することで、視覚的な一貫性と自然なトランジションを実現する手法です。これにより、ユーザーはどこからどこに遷移したのかを直感的に理解しやすくなります。

iOS 18の新しいAPIを使えば簡単に同様の効果を得られるようになりました。 また、セッションでは次のように説明されています。

アプリの中で大きなセルからのトランジションを行う場合、 ズームトランジションを使うことで、 トランジションの間も同じUI要素が継続して画面常に表示され、継続性をより強く表現できる。

全体のコード

実装した全体のコードはこちら

import SwiftUI

struct BraceletGridView: View {
    @Namespace private var namespace
    let columns = [
        GridItem(.flexible()),
        GridItem(.flexible())
    ]
    
    var body: some View {
        NavigationStack {
            ScrollView {
                LazyVGrid(columns: columns, spacing: 16) {
                    ForEach(0..<10) { index in
                        NavigationLink {
                            BraceletDetailView(braceletIndex: index)
                                .navigationTransition(.zoom(sourceID: "bracelet_\(index)", in: namespace))
                        } label: {
                            BraceletCell(index: index)
                        }
                        .matchedTransitionSource(
                            id: "bracelet_\(index)",
                            in: namespace
                        )
                    }
                }
                .padding(.horizontal)
            }
            .navigationTitle("Bracelets")
        }
    }
}

struct BraceletCell: View {
    let index: Int
    
    var body: some View {
        RoundedRectangle(cornerRadius: 10)
            .fill(Color.gray.opacity(0.2))
            .frame(height: 120)
            .overlay(
                Text("Bracelet \(index + 1)")
                    .foregroundColor(.black)
            )
    }
}

struct BraceletDetailView: View {
    let braceletIndex: Int
    @Namespace private var namespace
    
    var body: some View {
        VStack {
            RoundedRectangle(cornerRadius: 10)
                .fill(Color.gray.opacity(0.2))
                .frame(height: 200)
                .overlay(
                    Text("Bracelet \(braceletIndex + 1)")
                        .foregroundColor(.black)
                )
                .padding()
        }
    }
}

struct BraceletGridView_Previews: PreviewProvider {
    static var previews: some View {
        BraceletGridView()
    }
}

実装の注意点

ズームトランジションを使用するには、以下の2つが必要になります。

  1. navigationTransition(_:))モディファイアを使ってズームトランジションを指定する必要があります。 このモディファイアは、NavigationStackまたはシート(モーダルビュー)内でのみ使用できます(NavigationLinkでは動作しません)。

  2. matchedTransitionSource(id:in:configuration:))モディファイアを使用し、ズーム元のViewを認識させる必要があります。

感想

WWDCのセッション内の説明の通り、大きなセルからのトランジションの場合は、Push遷移よりもどこから遷移したのかわかりやすくなり、より直感的なアニメーションになると感じました。

ただ、デフォルトのアニメーションの遷移の速度(大きなセルから詳細画面への遷移)が早いような気もしています。 そのため、遷移の速度を調整するとより繊維がわかりやすくなるのかなと思いました(現に、WWDC内のアニメーションのデモの速度は半分になっています)。

こんな時代だからこそViewControllerの役割について振り返る

はじめに

ふとViewControllerの役割について気になったのでドキュメントを読んで、まとめてみる。 View Controller Programming Guide for iOS

ViewControllerの役割

ViewControllerの役割として以下を持ちます。

  • Viewの管理
  • イベントの処理
  • あるViewControllerから別のViewControllerへの遷移
  • アプリの他の部分との調整を行う

また、ViewControllerは2つの種類があります。ほとんどのアプリでは、両方のViewControllerが混在しています。

  • ContentViewController
    • アプリ内の特定のコンテンツを管理するために作成されるViewControllerのこと
    • UITableViewControllerUICollectionViewControllerが該当するような気がする
  • ContainerViewController
    • 他のViewController(child view controllers)をまとめて管理し、アプリ内のナビゲーションやコンテンツの表示方法を制御するViewControllerのこと
    • UINavigationControllerUITabBarControllerが該当する

Viewの管理について

ViewControllerの最も重要な役割は、Viewの階層を管理することです。

すべてのViewControllerには、ViewControllerのすべてのコンテンツを含む単一のRootViewがあります。 そのRootViewに、コンテンツを表示するために必要なViewを追加します。

図1-1は、ViewControllerとそのView間の組み込み関係を示しています。 ViewControllerには常にRootViewへの参照があり、各Viewにはsubviewsへの強参照があります。

ViewControllerとViewの関係

ContentViewControllerは、そのすべてのViewを独自に管理します。

一方で、ContainerViewControllerは、自身のViewに加えて、1つ以上の子ViewControllerのRootViewを管理します。 コンテナは、子のコンテンツを管理しません。 コンテナはRootViewのみを管理し、コンテナのデザインに従ってサイズと配置を決定します。 図1-2は、SplitViewControllerとその子の関係を示しています。 SplitViewControllerは、子Viewの全体的なサイズと位置を管理しますが、子ViewControllerは、それらのViewの実際のコンテンツを管理します。

図1-2 ViewControllerは他のViewControllerのコンテンツを管理できる

Data Marshaling(データマーシャリング)

アプリを開発する際、データはしばしば異なる形式で存在しています。 これらの形式の違いを解決するプロセスをデータマーシャリングといいます。 例えば、DBやAPIから取得したデータをアプリ内で使える形に変換する必要があります。 逆に、ユーザが入力したデータをサーバに送信する際も適切な形に変換する必要があります。

図1-3は、ViewControllerがデータ(Custom Data Object)とデータの表示に使用されるViewを参照している様子を表しています。 これらの間でデータを移動させるのは、開発者の責任です。

図1-3 ViewControllerはDataObjectとViewを仲介する

また、ViewControllerとDataObject(APIのレスポンスデータやCoreDataのデータ)の責務は常に分けるべきです。 データのバリデーションや保存処理は、DataObject内で行うべきです。 ViewControllerはViewからの入力を受け取り、その入力をDataObjectが要求する形に変換するかもしれませんが、ViewControllerが実際のデータを管理する役割は最小限にすべきです。

User Interactions(ユーザーインタラクション)

ViewControllerはレスポンダオブジェクトであり、Responder Chainに流れてくるイベントを処理することができます。 しかしながら、ViewControllerが直接タッチイベントを処理することはほとんどありません。

その代わりに、通常はViewが自身のタッチイベントを処理し、その結果を関連付けられたデリゲートメソッドやターゲットオブジェクト(通常はViewController)のメソッドに報告します。

このため、ViewController内のほとんどのイベントは、デリゲートメソッドまたはアクションメソッドを使用して処理されます。

リソース管理

UIViewController は基本的なビュー管理を自動で行い、ビューが不要になった場合にリソースを解放してくれます。 しかし、自分で作成したオブジェクトは、自分で管理しなければなりません。

アプリが動作している環境で空きメモリが不足した場合、システムはdidReceiveMemoryWarningというメソッドを呼び出して、不要なメモリを解放するように要求します。 一時的なデータや簡単に再作成できる情報はここで削除して、メモリを節約できます。

メモリを多く使用しすぎて解放できない状態が続くと、アプリがクラッシュする原因になります。 システムがメモリを回復するためにアプリを強制終了することがあるため、メモリの解放はパフォーマンスと安定性において非常に重要です。

Adaptivity(適応性)

ViewControllerは、アプリの画面表示を管理し、さまざまデバイスiPhoneiPadなど)の画面サイズの違いに応じてViewを調整する役割を持っています。

Viewを調整するために、iOSでは「サイズクラス」という概念を使います。 例えば、画面が広いとき(iPadなど)はコンテンツを横に広く配置し、画面が狭いとき(iPhoneなど)は縦に重ねて表示する、といった調整が行えます。

図1-4 サイズクラスの変更に合わせてViewを調整する

また、画面が回転したり、細かいサイズ変更があった場合でも、Auto Layoutを活用すれば、自動でビューのレイアウトを調整してくれます。 この仕組みによって、複数のデバイスや画面サイズに対応するシンプルで柔軟なUI設計が可能になります。

まとめていて思ったこと

ViewControllerが「FatViewController」になってしまう話をよく耳にしますが、 これはViewControllerに余計な責務を持たせていることが原因だと考えます。 自分の経験でも、DataObjectとの責務分担がうまくいっていないコードがありました。

例えば、画面遷移のロジックをRouterとして分離するなど責務を分割し、できるだけFatViewControllerにならないようにする工夫が必要だと思いました。

WWDCから見るSwift Concurrencyのasync/awaitを使うメリット

1. この記事で伝えたいこと

Swiftにおける非同期処理が、async/awaitの導入によりどのようにシンプルで安全になったかをWWDCの動画の内容を元に解説します。 従来のcompletion handlerとの違いを通じて、async/awaitの利点を理解しましょう。 developer.apple.com

2. 結論

async/awaitは、completion handlerと比べ以下の利点があります。

  • 非同期処理が同期処理のように自然に記述でき、コードの読みやすさが向上する
  • コードの記述量が減る
  • 確実に呼び出し元へ通知できるようになり、処理の抜け漏れがなくなる

3. completion handlerによる非同期処理の実装

まず、completion handlerを用いた従来の非同期処理の例を見てみましょう。

以下のコードは、非同期で画像をダウンロードし、サムネイル画像に変換する処理です。 このコードには問題点があります。さてどこでしょうか・・・?

func fetchThumbnail(for id: String, completion: @escaping (UIImage?, Error?) -> Void) {
    let request = thumbnailURLRequest(for: id)
    let task = URLSession.shared.dataTask(with: request) { data, response, error in
        if let error = error {
            completion(nil, error)
        } else if (response as? HTTPURLResponse)?.statusCode != 200 {
            completion(nil, FetchError.badID)
        } else {
            guard let image = UIImage(data: data!) else {
                return
            }
            image.prepareThumbnail(of: CGSize(width: 40, height: 40)) { thumbnail in
                guard let thumbnail = thumbnail else {
                    return
                }
                completion(thumbnail, nil)
            }
        }
    }
    task.resume()
}

それは以下のguard文の部分です。

            guard let image = UIImage(data: data!) else {
                return
            }
                guard let thumbnail = thumbnail else {
                    return
                }

guard文で早期リターンしており、エラーが発生しても呼び出し元に通知されません。 エラーが発生したときに、呼び出し元に通知するために以下のように修正する必要があります。

        guard let image = UIImage(data: data!) else {
                completion(nil, FetchError.badImage)
                return
            }
                guard let thumbnail = thumbnail else {
                    completion(nil, FetchError.badImage)
                    return
                }

このような呼び出し元に通知されない問題は検知されにくいです。 また、失敗が通知されない結果、ローディング画面が表示されたまま停止するなど、意図しない挙動が発生する可能性があります。

そのため、エラー処理のたびにcompletionを明示的に呼び出す必要があり、コードの冗長さが増します。

Result型を使うことで、呼び出し元で成功・失敗を明確に区別でき、エラーハンドリングが一貫性のある形で処理できますが、コードの可読性がさらに低下し、複雑になります。

// result型を使用したコード
func fetchThumbnail(for id: String, completion: @escaping (Result<UIImage, Error>) -> Void) {
    let request = thumbnailURLRequest(for: id)
    let task = URLSession.shared.dataTask(with: request) { data, response, error in
        if let error = error {
            completion(.failure(error))
        } else if (response as? HTTPURLResponse)?.statusCode != 200 {
            completion(.failure(FetchError.badID))
        } else {
            guard let image = UIImage(data: data!) else {
                completion(.failure(FetchError.badImage))
                return
            }
            image.prepareThumbnail(of: CGSize(width: 40, height: 40)) { thumbnail in
                guard let thumbnail = thumbnail else {
                    completion(.failure(FetchError.badImage))
                    return
                }
                completion(.success(thumbnail))
            }
        }
    }
    task.resume()
}

確かに長いし複雑ですね😭

4. async/awaitによる非同期処理の実装

上記の通りcompletion handlerを使用すると、完了の通知を送るかどうかは実装者依存になってしまい処理の抜け漏れが発生してしまいます。 そこで、async/awaitを使用すると、非同期処理がよりシンプルに記述でき、実装者に依存することがなくなります。

func fetchThumbnail(for id: String) async throws -> UIImage {
    let request = thumbnailURLRequest(for: id)
    let (data, response) = try await URLSession.shared.data(for: request)
    guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw FetchError.badID }
    let maybeImage = UIImage(data: data)
    guard let thumbnail = await maybeImage?.thumbnail else { throw FetchError.badImage }
    return thumbnail
}

また、非同期処理のフローが同期処理と同じように記述できます。 これにより、開発者の意図がコードに反映されやすく、読みやすさが向上します。

他にもthrowsを用いて、通常の関数と同様にエラーを投げられます。 これにより、エラー発生時に呼び出し元に確実に通知できるため、安全性が向上します。

参考

zenn.dev

developer.apple.com

www.youtube.com

docs.swift.org