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

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

チュートリアルは複数ありますのでそれぞれについて書いていきたいと思います。 長くなったので二部構成です。

後編はこちらです。

どのようなチュートリアルがあるかについては当ブログで記事が公開されているのでご参照ください。

Creating and Combining Views

最初のチュートリアルです。 このチュートリアルの成果物

SwiftUIの部品を作成すると最初からstructが2つ用意されています。View protocolにconformしたstructとPreviewProviderというprotocolにconformしたstructです。

たまにプレビュー(名称はCanvas)が表示されていない時があるのでその時は右上のResumeというボタンを押すとプレビューの描画が再読込されます。(後々別のチュートリアルで言及がありますが、プロパティの追加時などはResumeを押す必要があります。)

このチュートリアルでは必要な部品のレイアウトを垂直方向はVstack、水平方向はHstackを使って組んでいきます。UIStackViewっぽいです。空白を扱うSpacer、余白を扱うPadding、表示位置を変更するのにoffsetメソッドなどを使ったりもします。

最終的なメインのViewのコードは以下です。

<br />import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            MapView()
                .frame(height: 300)

            VStack(alignment: .leading) {
                Text("Turtle Rock")
                    .font(.title)
                HStack(alignment: .top) {
                    Text("Joshua Tree National Park")
                        .font(.subheadline)
                    Spacer()
                    Text("California")
                        .font(.subheadline)
                }
            }
            .padding()
        }
    }
}

struct ContentView_Preview: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

コードから画像のレイアウトが直感的に理解できます。

基本的に必要な部品を縦でも横でも順番に列挙していき、部品のカスタマイズを行いたい時はメソッドチェインをつなげて変更していきます。変更はすぐに反映され、ビルドを新たに行う必要はありません。また、View protocol のbodyプロパティにUI関連のコードがひとまとまりになります。

このチュートリアルではMKMapViewを利用していますが、従来のUIKitの部品を使用したい場合はそのstructをUIViewRepresentableというprotocolにconformさせる必要があります。

UIViewRepresentable

UIViewRepresentableにconformするために必要なメソッドは2つです。

  • makeUIView(context:)
  • updateUIView(_:context:)
  • makeCoordinator()

makeCoordinator()はUIKitのコンポーネントと連携するためのCoordinator インスタンスを作成するメソッドで、実装必須ですがデフォルト実装が用意されています。

通常の用途で自分達が実装するべきメソッドは残る2つです。

makeUIView(context:)は表示したいUIViewインスタンスを返すためのメソッドで、updateUIView(_:context:)は提示された UIView(およびコーディネーター)を最新のものに更新するメソッドです。

チュートリアルではMapKitをimportしてMKMapViewを表示するようにしていました。

makeUIView(context:)は表示したいUIViewまたはそのサブクラスを初期化してreturnします。その後初期表示に必要な実装、今までだとviewDidLoad()に記述していたようなことをupdateUIVeiw(_:context:)に記述しています。

成果物のコードから部品のカスタマイズを行うメソッドを取り除いたのが以下です。

struct MapView : UIViewRepresentable {
    func makeUIView(context: Context) -> MKMapView {
        MKMapView(frame: .zero)
    }

    func updateUIView(_ uiView: MKMapView, context: Context) {
        let coordinate = CLLocationCoordinate2D(latitude: 34.011286, longitude: -116.166868)
        let span = MKCoordinateSpan(latitudeDelta: 2.0, longitudeDelta: 2.0)
        let region = MKCoordinateRegion(center: coordinate, span: span)
        uiView.setRegion(region, animated: true)
    }
}

外部で定義したView protocolにconformしたstructを表示させたい場合はMapView()CircleImage()のように宣言するだけです。ひと通り画面に部品をのせたあと調整のためにoffsetメソッドやsafe areaを無視するedgeIgnoringSafeArea()などのメソッドを使用してレイアウトを完成させてこのチュートリアルは終了です。

このチュートリアルはこのような実装に関すること以外の基本的なSwiftUIでのレイアウトの組み方をXcodeに追加された新たな機能の解説とともに説明する内容でした。

Building Lists and Navigation

このチュートリアルの成果物

このチュートリアルを終えると,UIKitでいう所のUITableView風のレイアウトやUINavigationControllerを使った遷移をSwiftUIで行う方法を学ぶことができます。

ListとIdentifiable

単一の列に配置されたデータの行を表示するコンテナを用意するstructとしてListが提供されています。

List {
  Text("hoge1")
  Text("hoge2")
}

のように静的に記述することも可能ですが動的にListの要素を生成することができます。

生成元のデータコレクションと、コレクション内の各要素のビューを提供するContentクロージャを渡すことで、コレクションの要素を表示するリストを作成できます。

その際に生成元のデータは識別や区別が可能でないといけません。チュートリアルではidentfiableと表現されていました。

<br />struct LandmarkList: View {
    var body: some View {
        List(landmarkData.identified(by: \.id)) { landmark in

        }
    }
}

識別子と値のペアのコレクションを返せるように、keyPathでidentifierを指定するか、生成元のデータコレクションをIdenftifiableというprotocolにconformさせます。

Identifiableはidentityが等しいかどうか比較することができることを表すprotocolです。

IdentfiableにconformするにはObjectIdentifier型のidプロパティの実装が必要になるのですが、チュートリアルではIdentifiableの実装前にidプロパティを実装しているので、生成元のコレクションとして実装したLandmarkはIdentifiableを付与するだけで自動でconformします。

ちなみにObjectIdentifierのイニシャライザはinit(AnyObject)とinit(Any.Type)があり、サンプルコードはInt型でした。

IdentifiableにcoformしたオブジェクトはkeyPathを使わずにイニシャライザの引数にそのまま渡せます。

List(landmarkData.identified(by: \.id)) { landmark in
// Content: Viewを返す
}

このまま何も実装しないとCannot convert value of type '(_) -&gt; ()' to expected argument type '(_) -&gt; _'というエラーが表示されます。

コレクション内の各要素のビューを提供するクロージャになっていないといけません。

<br />struct LandmarkList: View {
    var body: some View {
        List(landmarkData) { landmark in
          LandmarkRow(landmark: landmark)
        }
    }
}

// ※参考 LandmarkRowの定義
struct LandmarkRow: View {
    var landmark: Landmark

    var body: some View {
        horizontalなstackに画像と文字列を並べている
        HStack {
            landmark.image(forSize: 50)
            Text(landmark.name)
        }
    }
}

これで動的なリスト表示が行えます。

NavigationView

Listの要素のタップ時に、UINavigationControllerで行なっていたような遷移のさせ方をSwiftUIで実現したい場合は、Navigationのクロージャの中にListを入れてListの要素のビュー提供するクロージャの中のタップさせたいビューをNavigationLinkで包むだけです。

NavigationLinkのイニシャライザの引数destinationに遷移先のインスタンスを渡します。 遷移後のNavigationBarTitleはnavigationBarTitle(_:)で指定します。

var body: some View {
    NavigationView {
        List(landmarkData) { landmark in
            NavigationLink(destination: LandmarkDetail()) {
                LandmarkRow(landmark: landmark)
            }
        }
        .navigationBarTitle(Text("Landmarks"))
    }
}

どこに遷移させたいのか、遷移後のバーのタイトルなどが一目でわかりやすいコードに感じます。

動的にプレビューを生成する

さまざまなデバイスサイズでプレビューをレンダリングする方法が解説されています。デフォルトでアクティブなSchemeのデバイスのサイズでレンダリングされます。

previewDevice(_:)メソッドを使って変更できます。 PreviewDeviceというstructのインスタンスを渡します。 単一のデバイスだとこれで良いのですが複数のデバイスのプレビューを一度に確認するためにForEachを使った書き方も紹介されています。

ForEachはListに加えて、コレクションからのビューの動的なリストを提供するstructです。

ForEach(["iPhone SE", "iPhone XS Max"].identified(by: \.self)) { deviceName in
    LandmarkList()
        .previewDevice(PreviewDevice(rawValue: deviceName))
        .previewDisplayName(deviceName)
}

Handling User Input

このチュートリアルではユーザーの操作で状態を変化させて、それをViewに反映させる方法をLandmarkにLikeをつける機能とLikeをつけたLandmarkをフィルタリングする機能を実装しつつ説明していく内容になっています。

最初にLandmarkというstructがisFavoriteプロパティをViewに反映させて、"Favorite Only"というボタンの状態によってLikeしたLandmarkのみの表示と全てのLandmarkの表示を切り替える所まで実装します。

その実装の際に使われる@Stateです。他のチュートリアルでもこれから何度も登場することになる@Stateですが、Swift5.1で実装されるProperty Wrappersという機能が使われた記法です。

Swift5.0までに追加された言語機能についていくつか記事を書いてきたようにSwift5.1で追加されたものに関しても今後記事を書きたいと思っていますので今回はProposalのリンクを掲載し、説明を省きます。

話を戻します。Stateが変更されるとStateが定義されているViewが再度レンダリングされます。

チュートリアルのコードだと @State attribute がついたプロパティ showFavoritesOnlyプロパティの値が、Toggleのイニシャライザの引数isOnに$というprefixを使用して渡され、State変数へのバインディングが実現されています。 これによりボタンの状態に合わせてshoFavoritesOnlyの値も変更され、それに連動してViewの再描画が走ります。それにより、Listの表示の切り替えが行われます。

struct LandmarkList: View {
    @State var showFavoritesOnly = true

    var body: some View {
        NavigationView {
            List {
                Toggle(isOn: $showFavoritesOnly) {
                    Text("Favorites only")
                }
                ForEach(landmarkData) { landmark in
                    if !self.showFavoritesOnly || landmark.isFavorite {
                        NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
                            LandmarkRow(landmark: landmark)
                        }
                    }
                }
            }
            .navigationBarTitle(Text("Landmarks"))
        }
    }
}

たまにリアルタイムにキャンバスが更新されない時があったのですが、プロパティの追加や変更など、ビューの構造を変更した場合は、キャンバスを手動で更新する必要があると@State attributesを付与したプロパティを定義する部分に書いてありました。

また、SwiftUIはOSSではありません。そのため、@Stateに関する詳細な仕様は想像するしかありません。まんまUIKitの時と同じです。

しかし、内部フィールドのdumpや@Stateの振る舞いからその内部仕様を追った記録ををkateinoigaku さんが公開されています。

  • [Inside SwiftUI @State編 - kateinoigakukunのブログ https://kateinoigakukun.hatenablog.com/entry/2019/06/08/232142]

また、Stateに関してAppleが公開しているドキュメントは以下になります。 - State - SwiftUI | Apple Developer Documentation

そして、上記のコードでBuilding Lists and Navigationで一度登場したForEachがまた使われています。 Listの中でstaticなViewとdynamicなViewを組み合わせる場合、またはstaticなViewの2つ以上の異なるグループを組み合わせる場合は、データのコレクションをListに渡すのではなく、ForEachに渡す必要があります。

物は試しということでForEachをListにしてみると、Listでもビルドはできますが、ForEachを使わずにListを使った場合の表示は以下のようにレイアウトが崩れたことを確認できました。

Bindable Object

表示の切り替えを実装した後はどのLandmarkをLikeしたかをユーザーが制御できるようにする機能を実装します。

その際Bindable Objectというのが登場します。 BIndable ObjectというのはViewのmodelとして振る舞うオブジェクトとドキュメントでは表現されています。

チュートリアルではSwiftUIのenvironment内のストレージからViewにバインドできるデータ用のカスタムオブジェクトと表現されていました。

実際にユーザーが管理するデータをBindableObjectというprotocolにconformさせるのでチュートリアルの表現の方で腑に落ちました。MVVMアーキテクチャに馴染みのある人はドキュメントの表現で十分理解できるかもしれません。

SwiftUIはBindable Objectに関するあらゆる変更を監視します。そして変更後に適切なViewを表示します。

Bindable Objectの型定義はprotocol BindableObject : AnyObject, DynamicViewProperty, Identifiable, _BindableObjectViewPropertyです。

状態を持つオブジェクトなので、定義通り参照型にcoformさせる必要があります。

Class-only protocolを定義する方法は: AnyObjectです。

以前は: classだったそうです。自分がSwiftを始めた時は既にAnyObjectでした。その変遷など詳しく知りたい方は以下のフォーラムなどを参照してください。

BindableObjectにcoformさせるclassの定義をチュートリアルから引用します。

import SwiftUI
import Combine

final class UserData: BindableObject {
    let didChange = PassthroughSubject<UserData, Never>()
    var showFavoritesOnly = false {
        didSet {
            didChange.send(self)
        }
    }
    var landmarks = landmarkData {
        didSet {
            didChange.send(self)
        }
    }
}

上記のコードから分かる通り、このチュートリアルからSwiftUIと合わせて大きく話題になっていたCombine frameworkが使用されています。

  • [Combine | Apple Developer Documentation https://developer.apple.com/documentation/combine]

didChangeプロパティのPassthroughSubjectはCombine frameworkが提供しているオブジェクトです。

PassthroughSubjectはCombine frameworkの簡単なPublisherであり、値を保持せずに即座にSubscriberに渡します。

SwiftUIは、このPublisherを通じてオブジェクトをsubscribeしてデータが変更されたときに更新が必要なViewを更新します。

bindableなオブジェクトはモデルのデータが更新される度にSubscriberに通知する必要があります。そのため、定義したデータはその変更をdidChangeを使ってpublishする必要があります。それがdidSetのブロック内の実装です。

LandmarkListの実装に定義したUserDataを組み込むときに気になるのは@EnvironmentObjectattributesだと思います。 @Stateで定義したプロパティと入れ替えるように指示があります。

@EnvironmentObjectはViewのヒエラルキー、階層構造の中でコンテキストを共有するために使います。

bindableなオブジェクトをViewの下位階層に渡すenvironmentObject(_:)メソッドが親のViewに適用されている限り、@EnvironmentObject attributeを与えられたuserDataプロパティは自動的に値を取得してきます。

struct LandmarkList: View {
    @EnvironmentObject var userData: UserData
}

ここで、このアプリのViewの階層を見て見ます。SceneDelegate.swiftに以下のようなコードがあります

window.rootViewController = UIHostingController(rootView: LandmarkList()
  .environmentObject(UserData())
)

コードが示す通り、Viewのヒエラルキーの最上位はLandmarkListのインスタンスです。その初期化時にメソッドチェインでenvironmentObject(_:)でUserDataのインスタンスを渡しています。その後LandmarkListはタップ時の遷移先のView(LandmarkDetail)に対してenvironmentObject(_:)self.userDataを渡しています。遷移先もプロパティ@EnvironmentObject var userData: UserDataを持っています。

EnvironmentObjectのドキュメント

You must set a model object on an ancestor view by calling its environmentObject(_:) method.

と記述がある通り、environmentObject(_:)は上位のViewによって必ず呼び出されなければいけません。

これがLandmarkListをrootとしたヒエラルキーにおいて、自動的にBindasbleObjectを伝搬する機能と変更を通知する機能が提供される仕組みです。

あとはその仕組みに則ってuserDataプロパティを使った実装に置き換えていくだけです。

その後、タップ後の遷移先、LandmarkDetailに対する実装ではButtonの実装程度で特に真新しいものはありませんでした。

Drawing Paths and Shapes

このチュートリアルではListで表示されているLandmarkにアクセスするたびに受け取るバッジを、PathsとShapesを使って作成するプロセスを学びます。

あらかじめバッジの六角形を描くための詳細を定義したHexagonParametersというstructが定義されていて、

Badge.swiftで、Pathを追加し、fillメソッドを使って図形をViewにします。 パスを使用して、線、曲線、その他の描画プリミティブを組み合わせて、バッジの六角形の背景のようなより複雑な形状を形成します。

Pathというstructを使って画像を描画する方法を一つずつ変化を確認しながら説明していく内容です。

書かれている通りに動かすだけなので、チュートリアルの中で一番時間がかからず終わりました。

Animating Views and Transitions

このチュートリアルの成果物

このチュートリアルではSwiftUIでViewやその状態の変化に対してアニメーションを適用させる方法が解説されています。

Animationというstructとアニメーションさせるいくつかのメソッドが中心になっています。

ボタンの表示非表示のタイミングにアニメーションを当てる実装は以下のようになります。

Button(action: {
    self.showDetail.toggle()
}) {
    Image(systemName: "chevron.right.circle")
        .imageScale(.large)
        .rotationEffect(.degrees(showDetail ? 90 : 0))
        .scaleEffect(showDetail ? 1.5 : 1)
        .padding()
        .animation(.basic())
}

scaleEffect(_:)はアニメーションのタイミングでViewの大きさを変更するために使われていました。基本的なアニメーションのさせ方はanimation(_:)にAnimation型の値を渡すことです。これで簡易的なアニメーションを適用できます。 animation(.spring())だと以下のような動きになります。

他にはwithAnimationtransition(_:)の使い方を知ることができます。キャンバスの左下のPinをタップするとキャンバスを固定できるので他のファイルからアニメーションの設定を変更する時はタップした状態で検証すると良いかもしれません。このあたりのアニメーションの実装の際のTipsもチュートリアルでは少ないですが紹介されています。

まとめ

SwiftUIは5.1で追加される言語機能をフルに活かして作られているので構文に面食らうこともあると思います。 チュートリアルで過不足のない説明がされていますので意識しなくてもチュートリアルを最後まで終えることはできます。 8つのSwiftUIのチュートリアルの内半分が終わりました。全てこの記事で説明すると長くなりすぎたので、次の記事で残りのチュートリアルについて書きます。

参考