ここまで簡単になるのか、SwiftUIチュートリアル2 〜Listの作成〜

こんにちは、WWDC4日目に参加しておりますiOSエンジニアの齋藤です。
本日木曜は、夜19時からbashという野外音楽フェスが行われるので早めにこの記事を書いております。
bashの模様は明日の記事であげますね。

さて、今回はSwiftUIチュートリアルの2に触れていきます。

f:id:kohei1218:20190604121533p:plain
引用:Apple

Building Lists and Navigation
※developerアカウントが必要ない、一般に公開されている内容になります。

はじめに

作成する内容はListが表示され、rowをタップすると詳細に遷移するアプリです。ListとはTableViewライクなViewです。
これ、実装してみて本当に驚きました。わざわざItemの数を管理しなくてよかったり、Selectの画面遷移が1行で書けたりとTableViewのかゆいところに見事に手が届いています。
今後のUI作成にかかる時間とコードは驚くほどに短縮されるのではないでしょうか。
Keynoteで発表された際の長いTableViewのコードが数行になるプレゼン、流石に多少盛っているかと思ったのですがまさかのそのままでした。
実装していて、インタラクティブかつスピーディーでにやけてしまいます。

f:id:kohei1218:20190607014612p:plain
引用:Apple

今回はAppleがListに表示するデータやチュートリアル1で作成したView(今回の詳細のViewがこれになる)を用意してくれたプロジェクトファイルがあるのでこれを使用しましょう。

f:id:kohei1218:20190607015136p:plain
引用:Apple

プロジェクトをダウンロードするとCompleteStartingPointというフォルダがあるのでStartingPointを開き、->Landmarks->BuildingListsAndNavigation.xcodeprojを開きます。

早速チュートリアルの順に中身を見ていきましょう。

チュートリアル1で作成したViewがLandmarkDetailという名前であり、これが詳細のViewに当たりそうです。

次にModelフォルダを見ていきましょう。
LandmarkDataというファイルがあります。
LandmarkはCodableを継承したModelのクラスになっており、DataはjsonファイルからLandmarkの配列を生成して返すものになっています。

Rowの実装

まずはListのRowを作成しましょう。TableViewでいうCellに当たります。

f:id:kohei1218:20190607031625p:plain
引用:Apple

cmd+Nで新規ファイル->SwifUI Viewを選択、LandmarkRowを作成します。
次にLandmarkのpropertyをおきます。

struct LandmarkRow : View {
    
    var landmark: Landmark
    
    var body: some View {
        Text("Hello World!")
    }
}

そうするとLandmarkRowの初期化にLandmarkのインスタンスが必要になるのでPreviewの方も修正します。

struct LandmarkRow_Previews: PreviewProvider {
    static var previews: some View {
        // landmarkDataはjsonからdecodeされたLandmarkの配列
        LandmarkRow(landmark: landmarkData[0])
    }
}

そして、TextをHStackViewの入れ子にし、上にlandmarkのimageをセット、Textにはlandmarkのnameをセットしてあげましょう。

struct LandmarkRow : View {
    
    var landmark: Landmark
    
    var body: some View {
        HStack {
            landmark.image(forSize: 50)
            Text(landmark.name)
        }
    }
}

これだけでCanvasのプレビューにはLandmarkRow_PreviewsでセットしたlandmarkData[0]のimageと名前が表示されています。うーむ、すごい。

f:id:kohei1218:20190607043622p:plain
引用:Apple

ちなみにLandmarkのimageはDataのImageStoreというクラスがjsonのimageNameからImageを生成して返しています。

final class ImageStore {
    fileprivate typealias _ImageDictionary = [String: [Int: CGImage]]
    fileprivate var images: _ImageDictionary = [:]
    
    fileprivate static var originalSize = 250
    fileprivate static var scale = 2
    
    static var shared = ImageStore()
    
    func image(name: String, size: Int) -> Image {
        let index = _guaranteeInitialImage(name: name)
        
        let sizedImage = images.values[index][size]
            ?? _sizeImage(images.values[index][ImageStore.originalSize]!, to: size * ImageStore.scale)
        images.values[index][size] = sizedImage
        
        return Image(sizedImage, scale: Length(ImageStore.scale), label: Text(verbatim: name))
    }

あとはLandmarkRow_Previewsで色々なパターンを見ていきましょう。
previewsのLandmarkRow(landmark: landmarkData[0])の箇所をcmdを押しながらクリックしてEmbed in Listを選択します。
あとは渡されるitemでlandmarkDataからLandmarkを取ればListのUIを確認できます。これだけでもうTableViewのUIできてますね。

struct LandmarkRow_Previews : PreviewProvider {
    static var previews: some View {
        List(0 ..< 5) { item in
            LandmarkRow(landmark: landmarkData[item])
        }
    }
}

Listの実装

Rowができたので次はListを実装していきます。
いつも通りSwift UI新規ファイル作成、今回はLandmarkListを作成します。
次にTextを消してListをおきます。Listはidentifiableなdataを扱います。なのでidentified(by:)を使用してidentifiableにするか、dataをIdentifiable protocolに準拠させる必要があります。

struct LandmarkList : View {
    
    var body: some View {
        List(landmarkData.identified(by: \.id)) { landmark in
            
        }
    }
}

or

// Identifiableを準拠
struct Landmark: Hashable, Codable, Identifiable {
...
}

struct LandmarkList : View {
    
    var body: some View {
        List(landmarkData) { landmark in
            
        }
    }
}

あとは先程作成したLandmarkRowをListの中に置いてあげればこれだけでListが完成しました。TableViewの労力とはえらい違いですね。

struct LandmarkList: View {
    var body: some View {
        List(landmarkData.identified(by: \.id)) { landmark in
            LandmarkRow(landmark: landmark)
        }
    }
}

f:id:kohei1218:20190607043703p:plain
引用:Apple

Navigationの実装

Listの親にNavigationViewをセットするだけで実装できます。

struct LandmarkList: View {
    var body: some View {
        NavigationView {
            List(landmarkData) { landmark in
                LandmarkRow(landmark: landmark)
            }
        }
    }
}

Navigationのtitleのセットもこれだけ

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

また自分が面白いと感じたのはタップ時の遷移でした。
RowをNavigationButtonの入れ子にし、destinationに遷移先をセットするだけでタップ時の遷移を実現できてしまいます。

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

f:id:kohei1218:20190607050725p:plain
引用:Apple

子Viewへのデータ受け渡し

最後に子のViewにデータを渡してあげてそれを表示してあげて、RootのViewを変更すれば完成です。
(コメントを書いてある箇所を追記しています。)

CircleImage

struct CircleImage: View {
    // imageのpropertyをセット
    var image: Image

    var body: some View {
        image
            .clipShape(Circle())
            .overlay(Circle().stroke(Color.white, lineWidth: 4))
            .shadow(radius: 10)
    }
}

struct CircleImage_Preview: PreviewProvider {
    static var previews: some View {
  // previewも修正
  CircleImage(image: Image("turtlerock"))
    }
}

MapView

struct MapView: UIViewRepresentable {
    // CLLocationCoordinate2Dのpropertyをセット
    var coordinate: CLLocationCoordinate2D

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

    func updateUIView(_ view: MKMapView, context: Context) {
        let span = MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02)
        let region = MKCoordinateRegion(center: coordinate, span: span)
        view.setRegion(region, animated: true)
    }
}

struct MapView_Preview: PreviewProvider {
    static var previews: some View {
        // previewも修正
        MapView(coordinate: landmarkData[0].locationCoordinate)
    }
}

LandmarkDetail

struct LandmarkDetail: View {
    var landmark: Landmark

    var body: some View {
        VStack {
            // CLLocationCoordinate2Dを渡す
            MapView(coordinate: landmark.locationCoordinate)
                .frame(height: 300)

            // Imageを渡す
            CircleImage(image: landmark.image(forSize: 250))
                .offset(y: -130)
                .padding(.bottom, -130)

            VStack(alignment: .leading) {
                Text(landmark.name)
                    .font(.title)

                HStack(alignment: .top) {
                    Text(landmark.park)
                        .font(.subheadline)
                    Spacer()
                    Text(landmark.state)
                        .font(.subheadline)
                }
            }
            .padding()

            Spacer()
        }
        // titleもセット
        .navigationBarTitle(Text(landmark.name), displayMode: .inline)
    }
}

struct LandmarkDetail_Preview: PreviewProvider {
    static var previews: some View {
        LandmarkDetail(landmark: landmarkData[0])
    }
}

LandmarkList

struct LandmarkList: View {
    var body: some View {
        NavigationView {
            List(landmarkData) { landmark in
                // landmarkを渡してあげる
                NavigationButton(destination: LandmarkDetail(landmark: landmark)) {
                    LandmarkRow(landmark: landmark)
                }
            }
            .navigationBarTitle(Text("Landmarks"))
        }
    }
}

struct LandmarkList_Previews: PreviewProvider {
    static var previews: some View {
        LandmarkList()
    }
}

SceneDelegate

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {

        let window = UIWindow(frame: UIScreen.main.bounds)
        // rootのViewを変更
        window.rootViewController = UIHostingController(rootView: LandmarkList())
        self.window = window
        window.makeKeyAndVisible()
    }

    // ...
}

お疲れ様でした。たったこれだけでNavigationのList、詳細のViewが完成してしまいました。いよいよTableViewとの決別を感じさせますね。
さて、明日はいよいよ最終日、WWDCのまとめとおすすめな過ごし方を紹介します!