SwiftUI チュートリアルを一通りやってみた- 後編 –

こんにちは。クラスメソッド 福岡オフィス CX事業本部でiOSアプリの開発に携わっている田辺です。 先日の三連休の間にSwiftUIのチュートリアルを一通りやってみました。リリース後に一読してはいたものの手を動かしていなかったので連休の間にやってみました。

二部構成になっています。前編はこちらです。

Composing Complex Interface

このチュートリアルではアプリのホーム画面を作っていきながら縦方向のListと横方向にスクロールできるViewを組み合わせたUIの作り方を解説しています。

ホーム画面の構成は以下のようになっていて カテゴリごとに所属するLandmarkを並べて横にスクロールできるようになっています。

このカテゴリの表示にはホーム画面用のView CategoryHome[String: [Landmark]]型のプロパティを使用します。

ScrollView

縦はListの中にForEachという過去のチュートリアルに出てきた構成です。横スクロールできる画面は初めて登場するScrollViewというstructを使います。

initializerでContentクロージャを必要とするのも今までのSwiftUIのオブジェクトと同じです。initializerには他にもスクロール方向を決めるAxis.Setとindicatorを表示するか決めるBool型の値を渡します。

チュートリアルではScrollViewがスクロールできるかPreviewProviderで表示するコレクションの要素数を増やしてさっと確認しています。

#if DEBUG
struct CategoryRow_Previews: PreviewProvider {
    static var previews: some View {
        CategoryRow(
            categoryName: landmarkData[0].category.rawValue,
            items: Array(landmarkData.prefix(4)) // ここの要素数を増やす
        )
    }
}
#endif

このScrollViewはUIScrollViewでできていたいくつかのことが今のところできないようです。

ホーム画面はView protocolにconformするために必要なbodyの実装でNavigationViewを返すようになっていて、navigationBarItems(trailing:)でPresetntaitonLinkというstructを使用しています。

PresentationLinkはUIKitのUIViewControllerのインスタンスメソッドpresent(_:animated:completion:)のようにモーダルのようなContentを表示するUIをタップ時に表示させるボタンを提供します。

SwiftではUIViewControllerが担っていた役割がどんどん削られているのがわかります。

Working with UI Controls

データバインディングの仕組みを用いてデータを元にUIの制御を行うためのチュートリアルです。 具体的に実装するものはユーザーのプロフィール画面とその編集画面です。

プロフィール画面はProfileHostというViewで、先ほど説明したPresentationLinkの遷移先です。ProfileHostが静的なプロフィール情報の表示(ProfileSummary)と編集画面(ProfileEditor)の表示両方を担当します。

プロフィール情報はProfileというsturctで管理します。

struct Profile {
    var username: String
    var prefersNotifications: Bool
    var seasonalPhoto: Season
    var goalDate: Date

    static let `default` = Self(username: "g_kumar", prefersNotifications: true, seasonalPhoto: .winter)

    init(username: String, prefersNotifications: Bool = true, seasonalPhoto: Season = .winter) {
        self.username = username
        self.prefersNotifications = prefersNotifications
        self.seasonalPhoto = seasonalPhoto
        self.goalDate = Date()
    }

    enum Season: String, CaseIterable {
        case spring = "🌷"
        case summer = "🌞"
        case autumn = "🍂"
        case winter = "☃️"
    }
}

ProfileHostがプロフィール情報の表示を担当するViewの 状態管理を行うため、プロフィール情報を表示するView(ProfileSummary)はプロフィール情報のバインディングは行わずにProfileインスタンスをイニシャライズ時に受け取って表示するだけの役割になっています

また、プロフィール情報の編集中、例えばユーザーが名前を入力している間など、編集を確認する前に状態が更新されないようにするために、編集画面(ProfileEdtior)にProfileHostはProfileのコピーを渡して編集完了後にProfileHostのプロパティに代入します。

データの流れはこんな感じなのですが編集中かどうかを管理するのは@Environmentattributeが与えられた変数modeで、 プロフィール情報と編集画面に渡すProfileのインスタンスは@State attributeをつけて管理します

struct ProfileHost: View {
    @Environment(\.editMode) var mode
    @State var profile = Profile.default
    @State var draftProfile = Profile.default
 }    

状態管理以外のViewの組み方などは今までのチュートリアルを全てやっていれば混乱することはありません。VStackの中に必要なViewを入れていくだけです。

また、ViewのインスタンスメソッドのonDisappear(perform:)が初めて登場します。編集画面がdisappearするときに編集画面に渡していたProfileのインスタンスをprofile変数に代入します。ここでもUIKitならUIViewControllerで実装していた機能がSwiftUIではViewに移譲されているのを感じます。

// 編集ボタンが有効かどうかで今編集画面かどうか分岐
if self.mode?.value == .inactive {
    ProfileSummary(profile: profile)
} else {
    ProfileEditor(profile: $draftProfile)
        // プロフィール編集画面が消えるとき(編集終了時)
        .onDisappear {
            self.draftProfile = self.profile
    }
}

Interfacing with UIKit

SwiftUIでは今のところUIKitで提供されていたコンポーネントを全てカバーできているわけではありません。そのようなSwiftUIで現状実現できないUIはUIKitのコンポーネントをSwiftUIと組み合わせて実装するためのAPIが提供されていて、このチュートリアルのテーマになっています。

一つ目のチュートリアルでMapKitをSwiftUIで使用するために使ったUIViewRepresentableというprotocolと、UIViewControllerをSwiftUIで使用するためのUIViewControllerRepresentableというprotocolを使ってSwiftUIでUIKitのコンポーネントを表示できます。

ライフサイクルの管理はSwiftUIがよしなにやってくれます。

このアプリではUIPageViewControllerを使うUIの構築のため、UIViewControllerRepresentableにconformするstructを定義します。

実際にチュートリアルで実装するUIは以下になります。

UIViewRepresentaleへのconformには表示するインスタンスを返すmakeUIView(context:)と表示後に最新の情報、設定で表示するためのupdateUIView(_:context)の実装が必要でした。

UIViewControllerRepresentableへのcoformにもそれぞれ

  • makeUIViewController(context:)
  • updateUIViewController(_:context:)

というインスタンスメソッドの実装が必要になります。命名から想像できますがUIViewRepresentableとメソッドの役割は同じです。

部品の実装を終えたところで表示用のPageViewというstructを定義します。チュートリアルはこのstructで先ほどの画像のようなUIを表示するよう実装を進めていく内容になっています。

ここでSwiftUIのViewのContext内でUIKitのUIViewControllerを使用するために必要なUIHostingControllerが登場します。

PageViewで表示したい複数の画面のViewを配列として受け取ってinitializerでUIHostingControllerのイニシャライザの引数rootViewに渡してmapでプロパティに配列して渡します。

struct PageView<Page: View>: View {
    var viewControllers: [UIHostingController<Page>]

    init(_ views: [Page]) {
        self.viewControllers = views.map { UIHostingController(rootView: $0) }
    }

    var body: some View {
        PageViewController(controllers: viewControllers)
    }
}

ここまでだとSwiftUIを使ってUIKitのコンポーネントを使えるものの、UIKitのコンポーネント実装の時にcoformしていたデリゲートやデータソースに関するprotocolの実装が行えていないです。

SwiftUIでそれを行うために CoordinatorとmakeCorrdinator()の実装をUIViewControllerRepresentableにcoformしたstructで行います。

struct PageViewController : UIViewControllerRepresentable {
    var controllers: [UIViewController]

    // makeUIViewController(context:)の前に呼び出される。
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    class Coordinator: NSObject, UIPageViewControllerDataSource {
        var parent: PageViewController

        init(_ pageViewController: PageViewController) {
            self.parent = pageViewController
        }
}

前回の記事で説明した通り、UIViewRepresentableのconformにはmakeUIView(context:)とupdateUIView(_:context:)に加えてmakeCoordinator()の実装が必要で、かつこのmakeCoordinator()はデフォルト実装が用意されているので通常は実装の必要がありません。

UIViewControllerRepresentableにもデフォルト実装ありのmakeCoordinator()メソッドがconformに必要とあります。

このCoordinatorを自分で定義するとmakeCoordinatorの実装も要求されます。

SwiftUIは、makeUIViewController(context :)の前にこのmakeCoordinator()メソッドを呼び出して、View Controllerを設定するときにCoordinatorにアクセスします。

そしてこのCoordinatorを使用して(必要なprotocolにcoformさせる)、デリゲートやデータソース、ユーザーの操作に対するイベントハンドリングのようなUIKitなどで実装していた一般的な処理をSwiftUIでも実装できます。

func makeUIViewController(context: Context) -> UIPageViewController {
        let pageViewController = UIPageViewController(
            transitionStyle: .scroll,
            navigationOrientation: .horizontal)
        //  必要なprotocolにconformしたCoordinatorをdataSourceプロパティに渡すことでSwiftUIからUIKitを制御する
        pageViewController.dataSource = context.coordinator // 

        return pageViewController
    }

このチュートリアルでは表示しているViewControllerのスワイプ切り替えを実現するためにUIPageViewControllerDataSouceのconformに必要なメソッドを実装しています。この部分はUIKitをこれまで使っていたら苦もなく理解できる内容だと思うのでこの記事で特に言及することはありません。

class Coordinator: NSObject, UIPageViewControllerDataSource {
    var parent: PageViewController

    init(_ pageViewController: PageViewController) {
        self.parent = pageViewController
    }

  // UIPageViewControllerDataSourceへのconformに必要なメソッドの実装
}

@Binding attributeを使ったデータバインディング

このチュートリアルでは@Bindingというattributeが登場します。 実装する機能はページビューで表示されているViewControllerが何番目かを保持するプロパティcurrentPageに使用されています。

Viewは値型です。子のViewに値を渡しても参照を渡すのではなく値をコピーして渡します。ここで@Bindingを使うのは親と子のViewの間でデータを同期するためです。

@Bindingは値を参照する側のプロパティに宣言します。 そして、親Viewの@State attributeがついた変数などの値の更新通知を受け取れます。

チュートリアルだとPageViewControllerです。 PageViewControllerのインスタンスの初期表示のためのメソッド、UIViewControllerRepresentableのupdateUIViewController(_: context:)でUIPageViewControllerのインスタンスメソッドsetViewControllers(_:direction:animated:)で表示するViewControllerの配列の添字として、PageViewControllerのcurrentPageは使われています。

つまりPageViewControllerでは、初期表示時にViewControllerの配列の何番目をページビューに表示するか管理するプロパティとしてcurrentPageが使われています。

struct PageViewController: UIViewControllerRepresentable {
    var controllers: [UIViewController]
    @Binding var currentPage: Int

    func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
        pageViewController.setViewControllers(
            [controllers[currentPage]], direction: .forward, animated: true) // ここ
    }

上記の説明の通り@Bindingは参照する側で参照される側の定義も必要です。 チュートリアルではPageViewが参照される側です。

struct PageView<Page: View>: View {
    var viewControllers: [UIHostingController<Page>]
    @State var currentPage = 0 // 参照される側のプロパティ定義

    var body: some View {
        PageViewController(controllers: viewControllers, currentPage: $currentPage) // 参照する側: PageVieControllerに渡している
    }    
}

そしてcurrentPageが変更されるべきタイミングはページビューで表示されるViewControllerが変更された時です。つまりスワイプで表示が切り替わったタイミングです。

このタイミングで値を更新するためにUIPageViewControllerDelegateのメソッドを実装します。ここはUIPageViewControllerDataSourceにconformする際と同じ流れです。

betaのバージョンごとの差異

始めて一通りのSwiftUIチュートリアルを読んだのはWWDCが終わってすぐで、手を動かしてチュートリアルをやったのはつい先日です。1度目と二度目でXocde 11 betaのバージョンが変わっていて命名の変更や初期化の方法が変わっているオブジェクトが多々あります。

beta2 から beta3でもScrillViewのinitの仕様やNavigationButtionからNavigationLinkへのリネームなど、多くの変更が加えられています。

詳細な変更点は以下のリンクから確認できます。今後当記事や前回の記事のコードで警告が出たりする場合はバージョンが理由かもしれません。よろしければご確認ください。

まとめ

3連休の間にSwiftUIのチュートリアルを終えることができました。betaリリース当初に一通り読みはしたものの、実際にやってみて、その内容を説明しようと記事を書く過程で疑問が解決したり、新しい疑問が浮かんできたりしました。

SwiftUIはもちろん、その他今年のWWDCで発表されたフレームワークやSwiftに関するセッション動画がたくさんあります。

  • [WWDC 2019 - Videos - Apple Developer] (https://developer.apple.com/videos/wwdc2019/?q=SwiftUI)

これまではSwift5.0までに関するSwiftについていくつか記事を書いてきましたが、目まぐるしく変わっていくiOS開発についていけるようにキャッチアップしていきたいです。

前回と今回の記事はチュートリアルをやりつつドキュメントを参照したり実際に実装してみたりした内容を説明した内容になっているため、誤りがあるかもしれません。 何かお気づきの際はコメントやTwitterなどからご指摘いただけるとありがたいです。