【入門者向け解説】SwiftUIのチュートリアルでiOSアプリ開発を体験してみたのでまとめ【第2回 ListとNavigation Link】

2021-11-17
Main Image

目次

こんにちは。

ふと「iOSアプリの開発ができるようになったら面白そうでは?」と思い至り、Apple公式チュートリアルを使ってアプリ開発の勉強をしてみました。

本記事では備忘録として要点をまとめておきます。

iPhoneのアプリってこうやって作るのか〜と入門者・初心者の方はイメージが湧くはず。

実際に挑戦してみたい方はこちらのApple公式チュートリアルに取り組んでみてください。(英語のみ)

チュートリアルサイトのスクリーンショット

こんな感じのサイトが公式で用意されていて、およそ4時間25分でチュートリアルを完了できるとのこと。

全体で4章にわかれていて、本記事は1章の中の2つめの内容をまとめてみます。

すでにチュートリアルに取り組んだ方も、本記事を読めば復習になるかと思います。ぜひ本文をチェックしてみてください。

第一回はこちら 【第一回 Viewで画面を構成】

チュートリアルの全体はこのようなChapter構成になっています。(タイトルは自己流で和訳しています)

  1. SwiftUIの基本
  2. 描画とアニメーション
    • PathsとShapes
    • Viewと遷移のアニメーション
  3. アプリデザインとレイアウト
    • 複雑なUIの組み合わせ
    • UIコントロール
  4. フレームワークの統合
    • UIKitを使ったインターフェース
    • watchOSアプリ
    • macOSアプリ

第一回のおさらい

SwiftUIチュートリアルでは「Landmark」というランドマークの写真や説明、マップ位置情報を表示するアプリを作っています。

第一回ではViewというアプリの画面構成をデザインする基本的な方法を学びました。Modifierを使って簡単に要素のデザインを変えることもできました。

チュートリアル1-1で出来上がるアプリの画面

今回の完成形のイメージ

1-1で作った画面に機能を追加して、このような画面ができます。

チュートリアル1-2の完成形

今回のチュートリアルのポイントは

  • リストで同じ構造のViewを繰り返し表示できる
  • ナビゲーションリンクが作成できる
    • リストをタップするとその中身の詳細画面(前回1-1で作った画面)にアクセスできる
  • 複数のデバイスでアプリの見た目のプレビューをシミュレーションできる

といった感じです。どれもアプリ開発で役に立ちそうな実用的な内容ですね!

作成するファイルの構成

それでは早速1-2で作成するファイル群を見てみましょう。

最終的に次の図の左側のような構成になりました。

ファイル構成とJSONファイルの中身

それでは順番に見ていきましょう。

必要なファイルをダウンロードしてプロジェクトフォルダに移動する

前準備として、チュートリアルのページからProject filesをダウンロードしてください。

以下の2つが必要になります。

  • landmarkData.json
    • Landmarkのプロジェクトフォルダにコピペする
  • Resources/Images/ 内のランドマーク画像ファイル群
    • アセットカタログにドラッグ・アンド・ドロップする

リストで繰り返し表示するモデルのJSONファイルを用意

まずはランドマーク情報を読み込む部分を作成します。

先程用意したJSONファイルはリストで取り出したい中身となるランドマークの情報です。

[
  {
    "name": "Turtle Rock",
    "category": "Rivers",
    "city": "Twentynine Palms",
    "state": "California",
    "id": 1001,
    "isFeatured": true,
    "isFavorite": true,
    "park": "Joshua Tree National Park",
    "coordinates": {
        "longitude": -116.166868,
        "latitude": 34.011286
    },
    "description": "hogehoge..."
  },
  ...
]

この内容をSwiftUIのViewからアクセスして、取り出していくわけです。

リストのように繰り返し同じ構造の情報を取り出したい場合は、JSONファイルで構造データとして記述することで動的に取り出すことができるようになります。つまり、ひとつひとつのランドマーク情報をViewに書き込む(=ハードコーディングする)必要がなくなります。

JSONファイルをSwiftUIで扱う方法については次に記載します。

繰り返し扱う構造の型をモデルとして定義し、JSONファイルをロードする

上記のJSONファイルで作成したランドマーク情報をプログラム内で効率よく扱うために、「型」というルールを定義します。これをモデルと呼びます。

ここでは2つのファイルを作成します。

  • Landmark.swift
  • ModelData.swift

作成する方法はXcodeのメニューでFile > New > File > Swift fileです。

モデルを定義するLandmark.swiftの中身は以下のようになります。

import Foundation
import SwiftUI
import CoreLocation

struct Landmark: Hashable, Codable, Identifiable {
    var id: Int
    var name: String
    var park: String
    var state: String
    var description: String

    private var imageName: String
    var image: Image {
        Image(imageName)
    }
    
    private var coordinates: Coordinates
    var locationCoodinate: CLLocationCoordinate2D {
        CLLocationCoordinate2D(
            latitude: coordinates.latitude,
            longitude: coordinates.longitude)
    }
    
    struct Coordinates: Hashable, Codable{
        var latitude: Double
        var longitude: Double
    }
}

設計したJSONファイルの構造に倣ってプロパティ(property)の型を定義しました。プロパティとはvarで宣言する変数のことです。

  • struct Landmark:に続く部分をプロトコルといいます。
    • Identifiableプロトコルを書いて要素を一意に定まっていることを宣言しないと、後のList()初期化でエラーになります。
  • CoreLocationはMapKitフレームワークのUIで使うライブラリです。
    • coordinateプロパティで位置情報を扱えるようにします

余談ですが、coordinateのスペルが難しすぎて途中でエラーになりました...

swift cannnot find coodinates in scope

import SwiftUIimport CoreLocationを書き忘れていても、同様のエラーが出て「scopeに見つからないよ」と注意されます。

続いてModelData.swiftの中身を見てみます。

import Foundation

var landmarks: [Landmark] = load("landmarkData.json")

func load<T: Decodable>(_ filename: String) -> T {
    let data: Data

    guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
    else {
        fatalError("Couldn't find \(filename) in main bundle.")
    }

    do {
        data = try Data(contentsOf: file)
    } catch {
        fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
    }

    do {
        let decoder = JSONDecoder()
        return try decoder.decode(T.self, from: data)
    } catch {
        fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)")
    }
}
  • 冒頭でlandmarks : という配列を宣言しload関数でJSONファイルを読み込む
  • 配列の中身はLandmark.swiftで定義したLandmarkの型
  • JSONファイルを読み込むためのload関数を宣言

呼び出しが関数より先にきても良いというのは独特ですね。

load関数の細かい説明はチュートリアルの中にもありませんでしたが、要はJSONファイルをロード(フェッチ)して、問題があればエラーを吐き出せるようになっています。(余裕があれば細かく読み込んで勉強したいところですが、そのままコピペでも他のアプリに使いまわせそうです。)

リストで繰り返し表示する「1行」を作成する

リストを表示するために、次の2つのSwiftUI Viewファイルを作成します。

  • LandmarkRow.swift
  • LandmarkList.swift

それぞれ「行」と「行を繰り返し表示するリスト」の関係です。

LandmarkRow.swiftの中身はこんな感じ

import SwiftUI

struct LandmarkRow: View {
    var landmark: Landmark
    
    var body: some View {
        HStack{
            landmark.image
                .resizable()
                .frame(width: 50, height: 50)
            Text(landmark.name)
            
            Spacer()
        }
    }
}

struct LandmarkRow_Previews: PreviewProvider {
    static var previews: some View {
        Group{
            LandmarkRow(landmark: landmarks[0])
            LandmarkRow(landmark: landmarks[1])
        }
        .previewLayout(.fixed(width:300,height:70))
    }
}

Viewブロックでは先程作ったモデルをlandmarkというプロパティとして宣言し、そのimagenameを水平に配置しています。あとSpacer()も並べて右側を余白にしています。

PreviewProviderブロックではJSONファイルを読み込んだlandmarks配列から1番目[0]と2番目[1]をViewブロックに渡しています。Group{}というコンテナでくくってModifierを後に繋げるとコンテナ内の子要素すべてに適用されます。

こんな感じでプレビューされました。これらがリストに表示される「1行」です。

LandmarkRowのPreview画面

リストを表示させ、それぞれの行にナビゲーションリンクをつける

次にLandmarkList.swiftの中身を見てみます。今回のキモ。

import SwiftUI

struct LandmarkList: View {
    var body: some View {
        NavigationView{
            List(landmarks) {
                landmark in
                NavigationLink{
                    LandmarkDetail(landmark: landmark)
                } label: {
                    LandmarkRow(landmark: landmark)
                }
            }
            .navigationTitle("Landmarks")
        }
    }
}

分解してみてみると以下のようになっています。

  • List(landmarks) { landmark in ... }
    • この部分でlandmarks配列の中身をlandmarkとして取り出して繰り返し処理します。
  • NavigationLink{B} label:{A}
    • この部分でAをラベルとして表示し、タップするとBに遷移するリンクを作成します。
  • .navigationTitle("")
    • この部分でリストのタイトルをつけます
  • 上記すべてをNavigationView{}で括るとナビゲーションとして機能するViewが作成されます

これで冒頭の完成形スクショ画像のようなリスト型のナビゲーションリンクが作成できました。

補足 : ListのIdentifierについて

リストの初期化としてlandmarks配列を呼び出していますが、このとき配列の要素はidなど固有の値で識別できるようにする必要があります。

2種類のやり方があります。

  • List(landmarks, id: \.id)で初期化する
  • Landmark.swiftにidentifiableプロトコルを追加する

識別できないとこのようなエラーが出ます。

Initializer 'init(_:rowContent:)' requires that 'Landmark' conform to 'Identifiable'

モデルを使って動的にViewを表示する

続いて、ナビゲーションリンクをタップしてアクセスするページにランドマーク情報を表示する部分を実装します。

前回はランドマークturtlerockの情報をハードコーディングしていたので、この部分を今回作成したモデルLandmarkから引っ張ってくることで、動的なViewを作成することができます。

前回作ったContentViewbody中身をLandmarkDetail.swiftという新規ファイルのbodyにコピペし、ハードコーディングしたturtlerockの情報の代わりにlandmarkモデルの属性を呼び出します。

import SwiftUI

struct LandmarkDetail: View {
    var landmark: Landmark // モデルをlandmarkプロパティにセット
    
    var body: some View {
        ScrollView{
            MapView(coordinate: landmark.locationCoodinate) // 位置情報
                .ignoresSafeArea(edges: .top)
                .frame(height: 240)
                
            
            CircleImage(image: landmark.image) // 画像
                .offset(y:-130)
                .padding(.bottom, -130)
            
            VStack(alignment: .leading) {
                Text(landmark.name) // ランドマーク名
                    .font(.title)
                HStack {
                    Text(landmark.park) // 公園名
                        .font(.subheadline)
                    Spacer()
                    Text(landmark.state) // 州
                        .font(.subheadline)
                }
                .font(.subheadline)
                .foregroundColor(.secondary)
                
                Divider()
                
                Text("About \(landmark.name)")
                    .font(.title2)
                Text(landmark.description) // 説明文
            }
            .padding()
        }
        .navigationTitle(landmark.name) // タイトル
        .navigationBarTitleDisplayMode(.inline)
    }
}

struct LandmarkDetail_Previews: PreviewProvider {
    static var previews: some View {
        LandmarkDetail(landmark: landmarks[1]) // プレビュー用に配列の1つ目をセット
    }
}

また、前回作成した以下のViewにもハードコーディングされている箇所があるため、書き換えます。

MapView.swift

  • coordinateプロパティを追加
    • PreviewProviderにプレビュー用のサンプルパスを渡す
  • setRegionメソッドを追加
    • coordinateの値に応じてregionを更新する
    • onAppear modifierを追加し、現在のcoordinateに応じてregionを更新する計算を走らせる
import SwiftUI
import MapKit

struct MapView: View {
    var coordinate: CLLocationCoordinate2D
    @State private var region = MKCoordinateRegion()
    
    var body: some View{
        Map(coordinateRegion: $region)
            .onAppear{
                setRegion(coordinate)
            }
    }
    
    private func setRegion(_ coordinate: CLLocationCoordinate2D){
        region = MKCoordinateRegion(
        center: coordinate,
        span: MKCoordinateSpan(latitudeDelta: 0.2, longitudeDelta: 0.2)
        )
    }
}

struct MapView_Previews: PreviewProvider {
    static var previews: some View {
        MapView(coordinate: CLLocationCoordinate2D(latitude: 34.011_286, longitude: -116.166_868))
    }
}

CircleImage.swift

  • imageプロパティを追加
    • PreviewProviderにプレビュー用のサンプルパスを渡す
import SwiftUI

struct CircleImage: View {
    var image: Image
    
    var body: some View {
        image
            .clipShape(Circle())
            .overlay{
                Circle().stroke(.white, lineWidth: 4)
            }
            .shadow(radius: 7)
    }
}

struct CircleImage_Previews: PreviewProvider {
    static var previews: some View {
        CircleImage(image: Image("turtlerock"))
    }
}

以上の作業が終わると、こんな感じにプレビューされます。各属性がJSONファイルから正しく読み込めてます。

LandmarkDetailのプレビュー画面

ナビゲーションリンクの動作確認

ContentViewの中身はLandmarkDetailに移動したので、元のContentViewからはLandmarkListを参照するようにします。アプリの入り口をリストのナビゲーションリンクにするためです。

ContentView.swift

struct ContentView: View {
    var body: some View {
        LandmarkList()
    }
}

LivePreviewモードでListをクリック(タップ)すると、Detailページに移動することが確認できました。

また、Detailページには左上にListに戻るボタンも自動で付加されています。これは便利。

ファイルのグループ分け

作成したファイルが増えてきたのでグループごとにまとめます。

方法は、まとめたいファイルを選択してからFile > New > Group from Selectionでグループを作成できます。

Views, Model, Resourcesといったグループに分類しました。

ファイル構成とJSONファイルの中身

(冒頭と同じ図です)

アプリ画面をシミュレーションするデバイスを並べる

繰り返し処理にはList{}の代わりにForEach()文も使えます。

これで複数のデバイスで表示される見た目を同時にプレビューできます。めっちゃメモリ食いそう。

LandmarkList.swift

struct LandmarkList_Previews: PreviewProvider {
    static var previews: some View {
        ForEach(["iPhone SE (2nd generation)", "iPhone XS Max"], id: \.self) { deviceName in
                    LandmarkList()
                        .previewDevice(PreviewDevice(rawValue: deviceName))
                        .previewDisplayName(deviceName)
                }
    }
}

以上で全ファイルの中身を見終えました。お疲れさまでした。

まとめ

というわけでSwiftUIを使ったiOSアプリ開発をやってみよう!ということでApple公式チュートリアルのChapter1-2をまとめてみました。

今回の要点

  • Listと、Identifiableもしくはidを使って動的に要素が並んだリストが作成できる
  • NavigationLinkで画面タップして遷移するリンクを作成できる
  • 繰り返し処理にはListの他にForEach文も使える

次回はChapter1-3でユーザ入力に対応させます。この調子でSwiftUIを学んでいきたいと思います。

オマケ : SwiftUIの独学に役立つ参考書籍

SwiftUIの解説本も発売されており、Kindleでも読めます。

こちらの本は発行が2019/12と少し古いですが、一番詳しく書かれている本のようです。(by Amazonレビュアー)

他の本は軒並み評価が低いので、買うとしたらコレかと。

ご参考になれば幸いです。

それでは〜

ads【オススメ】未経験からプログラマーへ転職できる【GEEK JOBキャンプ】
▼ Amazonオススメ商品
ディスプレイライト デスクライト BenQ ScreenBar モニター掛け式
スマートLEDフロアライト 間接照明 Alexa/Google Home対応

Author

Penta

都内で働くITエンジニアもどき。好きなものは音楽・健康・貯金・シンプルでミニマルな暮らし。AWSクラウドやデータサイエンスを勉強中。学んだことや体験談をのんびり書いてます。TypeScript / Next.js / React / Python / AWS / インデックス投資 / 高配当株投資 More profile

Location : Tokyo, JPN

Contact : Twitter@penguinchord

Recommended Posts

Copy Right / Penguin Chord, ペンギンコード (penguinchord.com) 2022 / Twitter@penguinchord