【SwiftUI】外部APIを叩いて取得した結果をListに表示する

## はじめに

そういえば Apple の SwiftUI のチュートリアルには，
通信して取得したデータを表示する系のチュートリアルがなかったな
ということで SwiftUI の勉強会の LT 発表ネタにしようと思って
サンプルアプリを作ってみることにしました。

何を題材にしよう？と考えて，
勉強会なので connpass のイベントサーチAPI 使おうと決めました。

Enjoy SwiftUI vol2 で一部ライブコーディングした内容です。

## ObservableObject と ObservedObject

SwiftUI の View 周りの実装だけやるのであれば不要な知識なのですが，
データが絡まないことは実際のアプリだとほとんどないですよね。
ちょっと敬遠してた `ObservableObject` と `ObservedObject` を
確認することにしました。

### ObservableObject
`ObservableObject` はプロトコルでこのプロトコルに準拠すると
そのクラスはイベントが発行できるようになる。

例えば `ViewModel` クラスなどで使い，
値が変わったら `View` クラスに通知して再描画させる感じ。
今回のサンプルアプリでは通信するクラスで使います。

### @Published

プロパティラッパで，`ObservableObject` に準拠したクラスで
この修飾子をつけたプロパティの値の更新を通知できる。
今回はイベントのデータモデル配列のプロパティにつけて 
`View` 側に更新を通知しています。

### @ObservedObject

こちらもプロパティラッパで `ObservableObject` に準拠した，
クラスを宣言する際にプロパティにつける。

今回は，`List` を実装する `View` で通信クラスを宣言する際につけています。

## 今回作るサンプルアプリ

### 実装画面

実装する画面は 3画面です。
この記事ではイベントリスト画面のみ扱います。
ダークモード，Dynamic Type は対応できるようにします。
(実装してて特に意識する必要のないのが **SwiftUI のいいところ！**)

* 勉強会のイベントリスト画面
* イベント詳細画面(のつもり)
* 取得したイベントURLを開く WebView(SafariViewController)

|top|detail|safari|
|:--:|:--:|:--:|
|<img width="392" alt="topListView.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88266/56eba7a4-3796-2fc3-7c29-ae805c964789.png">|<img width="391" alt="detailView.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88266/614fe3b7-434a-3cd8-a864-9ebe3e775aea.png">|<img width="400" alt="safariView.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88266/2485d76d-8e52-98d1-040f-c9c74a35146e.png">|


### 実装環境

* macOS Catalina 10.15
* Xcode 11.1
* Swift 5

### サンプルプロジェクト

GitHub に Push しました。気になる方はご覧ください。
[https://github.com/MilanistaDev/StudyGroupEventFetcherForSwiftUI](https://github.com/MilanistaDev/StudyGroupEventFetcherForSwiftUI)

![QR_Code_1573463583.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88266/57903af0-cd1d-4faf-40c0-36c19a9ebe90.png)

## 今回使用するAPI

今回はゆめみの勉強会(YUMEMI.swift)の一覧を取得してみようと思い，
connpass のイベントサーチ API[^1] を用いました。
(API が充実してないなぁ・・・)

### リクエストするURL

検索クエリは単純にキーワードで **YUMEMI.swift** で検索します。
よってリクエストする URL は下記になります。

`https://connpass.com/api/v1/event/?keyword=YUMEMI.swift`

### レスポンスをもとにモデルを作成

API 仕様[^1] をもとに必要な情報だけ抜き出したものが下記になります。

```json
{
  "events": [
    {
      "event_url": "https://イベントのURL",
      "started_at": “yyyy-mm-ddThh:mm:ss+09:00", // ISO8601 形式
      "hash_tag": "Twitterのハッシュタグ",
      "title": "勉強会タイトル",
      "event_id": 123456,
      "owner_display_name": "管理者のdisplayネーム",
      "address": "住所",
    },
    …
  ]
}
```

よってまず events を受けるモデルを作成します。

<details><summary>**タップしてモデルのコードを見る**</summary><div>

```swift:StudyGroup.swift
import Foundation

struct StudyGroup: Decodable {
    var events: [Event]
}
```
</div></details>


次にイベントのモデルを作成します。

<details><summary>**タップしてイベントモデルのコードを見る**</summary><div>

```swift:Event.swift
import Foundation

struct Event: Decodable, Identifiable {
    var id: Int
    var title: String
    var eventUrl: String
    var hashTag: String
    var startDate: String
    var address: String
    var ownerDisplayName: String

    enum CodingKeys: String, CodingKey {
        case id = "event_id"
        case title = "title"
        case eventUrl = "event_url"
        case hashTag = "hash_tag"
        case startDate = "started_at"
        case address = "address"
        case ownerDisplayName = "owner_display_name"
    }
}
```
</div></details>

モックデータとして何個かデータを用意します。
`StudyGroup` モデルの中に用意しました。

<details><summary>**タップしてモックデータのコードを見る**</summary><div>

```swift
let mockEventsData: [Event]
    = [Event(id: 1,
             title: "YUMEMI.swift #1 ~WWDC19報告会~",
             eventUrl: "https://yumemi.connpass.com/event/131175/",
             hashTag: "yumemi_swift",
             startDate: "2019-06-24T19:00+09:00",
             address: "東京都世田谷区",
             ownerDisplayName: "株式会社ゆめみ"),
       Event(id: 2,
             title: "YUMEMI.swift #1 ~WWDC19報告会~ パプリックビューイング @Sapporo",
             eventUrl: "https://yumemi.connpass.com/event/135183/",
             hashTag: "yumemi_swift",
             startDate: "2019-06-24T19:00:00+09:00",
             address: "北海道札幌市中央区",
             ownerDisplayName: "株式会社ゆめみ"),
       Event(id: 3,
             title: "Enjoy SwiftUI vol1",
             eventUrl: "https://yumemi.connpass.com/event/139079/",
             hashTag: "yumemi_swift",
             startDate: "2019-07-31T19:00:00+09:00",
             address: "東京都世田谷区",
             ownerDisplayName: "株式会社ゆめみ"),
       Event(id: 4,
             title: "YUMEMI.swift #3 ~俺/私がやったiOS 13対応~",
             eventUrl: "https://yumemi.connpass.com/event/142608/",
             hashTag: "yumemi_swift",
             startDate: "2019-09-30T19:00:00+09:00",
             address: "東京都世田谷区",
             ownerDisplayName: "株式会社ゆめみ"),
       Event(id: 5,
             title: "Enjoy SwiftUI vol2",
             eventUrl: "https://yumemi.connpass.com/event/151594/",
             hashTag: "yumemi_swiftui",
             startDate: "2019-11-08T19:00:00+09:00",
             address: "東京都世田谷区",
             ownerDisplayName: "株式会社ゆめみ"),
       Event(id: 6,
             title: "YUMEMI.swift #5",
             eventUrl: "",
             hashTag: "yumemi_swift",
             startDate: "2019-11-28T19:00:00+09:00",
             address: "東京都世田谷区",
             ownerDisplayName: "株式会社ゆめみ")
]
```
</div></details>

## 実装

### 事前準備

用意したモックのデータを使って `List` に表示するところまでやっておきます。

#### イベントリスト画面

`List` を実装するビューを `TopListView` とします。
`NavigationLink` で イベント詳細画面 (`EventDetailView`) に Push 遷移させています。
スタックされるビュー(セル)を `EventRowView` として実装しています。
`Event` 型のモデルがそれぞれの画面に渡してあります。

```swift:TopListView.swift

import SwiftUI

struct TopListView: View {

    let eventsData: [Event] = mockEventsData

    var body: some View {
        NavigationView {
            List(eventsData) { event in
                NavigationLink(destination: EventDetailView(eventData: event)) {
                    EventRowView(eventData: event)
                }
            }
            .navigationBarTitle(Text("YUMEMI.swift一覧"))
        }
    }
}
```

プレビュー画面はこのような表示です。

<img width="392" alt="topListView.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88266/56eba7a4-3796-2fc3-7c29-ae805c964789.png">


#### セルのビュー

セル相当の実装を `Extract Subview` してクラスに分けています。
シンプルに `VStack` と `HStack` を使ってビューを実装しています。
`Event` 型のモデルを受けて各ビューはそのデータを使います。
プレビューではモックのデータの最初のモデルデータを使っています。
アイコンは **SF Symbols** を使っています。

<details><summary>**タップしてセルのビューのコードを見る**</summary><div>

```swift:EventRowView.swift

import SwiftUI

struct EventRowView: View {

    var eventData: Event

    var body: some View {
        VStack(alignment: .leading) {
            Text(eventData.title)
                .bold()
                .font(.headline)
                .lineLimit(2)
                .padding(Edge.Set.top, 8.0)
                .padding(Edge.Set.bottom, 12.0)
            HStack {
                Image(systemName: "calendar")
                    .imageScale(.medium)
                    .foregroundColor(.red)
                Text(eventData.startDate).font(.footnote)
            }.padding(Edge.Set.bottom, 6.0)
            HStack {
                Image(systemName: "person.fill")
                    .imageScale(.medium)
                    .foregroundColor(.red)
                Text(eventData.ownerDisplayName + " 他").font(.footnote)
            }.padding(Edge.Set.bottom, 6.0)
            HStack {
                Image(systemName: "mappin.and.ellipse")
                    .imageScale(.medium)
                    .foregroundColor(.red)
                Text(eventData.address)
                    .font(.footnote)
                    .lineLimit(3)
            }.padding(Edge.Set.bottom, 4.0)
            HStack {
                Spacer()
                Text("#" + eventData.hashTag)
                    .foregroundColor(.blue)
                    .font(.caption)
                    .padding(Edge.Set.bottom, 8.0)
            }
        }
    }
}

struct EventRowView_Previews: PreviewProvider {
    static var previews: some View {
        EventRowView(eventData: mockEventsData[0])
    }
}
```
</div></details>

プレビュー画面は下記のようになります。

<img width="390" alt="rowView.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88266/82edc046-cb3d-3cc6-a296-90c8bc41488d.png">


### 本実装

実際に通信して取得したデータを `List` に表示させます。
まずは通信する部分のコードを実装します。
今回は `URLSession` を使ってみます。
SwiftUI を意識しなくていい一般的なコードです。

<details><summary>**タップして通信クラスのコードを見る**</summary><div>

```swift:StudyGroupEventFetcher.swift

import Foundation

class StudyGroupEventFetcher {

    // connpass's event search API
    private let urlLink = "https://connpass.com/api/v1/event/?keyword=YUMEMI.swift"
    var eventData: [Event] = []

    init() {
        fetchEventData()
    }

    func fetchEventData() {
        URLSession.shared.dataTask(with: URL(string: urlLink)!) { (data, response, error) in
            guard let data = data else { return }
            let decoder: JSONDecoder = JSONDecoder()
            do {
                let searchedResultData = try decoder.decode(StudyGroup.self, from: data)
                DispatchQueue.main.async {
                    self.eventData = searchedResultData.events.reversed()
                }
            } catch {
                print("json convert failed in JSONDecoder. " + error.localizedDescription)
            }
        }.resume()
    }
}
```
</div></details>

次にこの通信クラスを `ObservableObject` に準拠させて，
通信が終わって `eventData` のプロパティに `@Publushed` をつけて
取得データを代入した際にイベントを通知するように書き換えていきます。

```swift:StudyGroupEventFetcher.swift

import Foundation

class StudyGroupEventFetcher: ObservableObject {

    // connpass's event search API
    private let urlLink = "https://connpass.com/api/v1/event/?keyword=YUMEMI.swift"
    // このプロパティに変更があった際にイベント発行
    @Published var eventData: [Event] = []

    init() {
        fetchEventData()
    }

    func fetchEventData() {
        URLSession.shared.dataTask(with: URL(string: urlLink)!) { (data, response, error) in
            guard let data = data else { return }
            let decoder: JSONDecoder = JSONDecoder()
            do {
                let searchedResultData = try decoder.decode(StudyGroup.self, from: data)
                DispatchQueue.main.async {
                    self.eventData = searchedResultData.events.reversed()
                }
            } catch {
                print("json convert failed in JSONDecoder. " + error.localizedDescription)
            }
        }.resume()
    }
}
```

最後に，通知を受けてビューを更新できるようにします。

通知を受けるのは `TopListView` ですね。
通信クラスを宣言して @ObservedObject をつけます。
モックデータを使っていたところを
通信クラスの `eventData` のプロパティに変更します。

```swift:TopListView.swift

import SwiftUI

struct TopListView: View {

    // モックのデータは使わない
    // let eventsData: [Event] = mockEventsData

    // ObservableObject に準拠したクラスを監視
    @ObservedObject var fetcher = StudyGroupEventFetcher()

    var body: some View {
        NavigationView {
            // 通信クラスの eventData プロパティを設定
            List(fetcher.eventData) { event in
                NavigationLink(destination: EventDetailView(eventData: event)) {
                    EventRowView(eventData: event)
                }
            }
            .navigationBarTitle(Text("YUMEMI.swift一覧"))
        }
    }
}
```

これだけです。

## 結果

YUMEMI.swift で検索した結果が返ってきて `List` 表示できます。
ダークモードにも対応できてますね。Dynamic Type も然りです。

![68367755-be15ed00-0179-11ea-8d79-b04930818aa1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88266/542c3a34-153a-daa7-6604-78320dc676fc.gif)


## おまけ ~MVVM化~

ViewModel に 通知の役割を任せて **MVVM** にしてみます。
通信クラスを通信結果を返すだけに書き換えます。

<details><summary>**タップして修正した通信クラスのコードを見る**</summary><div>

```swift:StudyGroupEventFetcher.swift

import Foundation

class StudyGroupEventFetcher {

    // connpass's event search API
    private let urlLink = "https://connpass.com/api/v1/event/?keyword=YUMEMI.swift"

    func fetchEventData(completion: @escaping ([Event]) -> Void) {
        URLSession.shared.dataTask(with: URL(string: urlLink)!) { (data, response, error) in
            guard let data = data else { return }
            let decoder: JSONDecoder = JSONDecoder()
            do {
                let searchedResultData = try decoder.decode(StudyGroup.self, from: data)
                DispatchQueue.main.async {
                    completion(searchedResultData.events.reversed())
                }
            } catch {
                print("json convert failed in JSONDecoder. " + error.localizedDescription)
            }
        }.resume()
    }
}
```
</div></details>

`TopListViewModel` を作成し実装します。
このクラスで `TopListView` に通知をするので
`ObservableObject` に準拠させます。

`ViewModel` 生成時に通信するようにして結果を
`@Published` をつけたプロパティに代入してイベント発行させます。

```swift:TopListViewModel.swift

import Foundation

class TopListViewModel: ObservableObject {
    let fetcher = StudyGroupEventFetcher()
    @Published var eventData: [Event] = []

    init() {
        self.fetcher.fetchEventData { (events) in
            self.eventData = events
        }
    }
}
```

最後に，`TopListView` 側で `ViewModel` を使うようにします。

```swift:TopListView.swift

import SwiftUI

struct TopListView: View {

    // モックのデータは使わない
    // let eventsData: [Event] = mockEventsData

    // ObservableObject に準拠したクラスを監視
    // @ObservedObject var fetcher = StudyGroupEventFetcher()

    // ViewModel を生成し監視する
    @ObservedObject var topListVM = TopListViewModel()

    var body: some View {
        NavigationView {
            // ViewModel の eventData プロパティを設定
            List(topListVM.eventData) { event in
                NavigationLink(destination: EventDetailView(eventData: event)) {
                    EventRowView(eventData: event)
                }
            }
            .navigationBarTitle(Text("YUMEMI.swift一覧"))
        }
    }
}
```

動作結果は同じです。


## おわりに

今回は connpass のイベントサーチ API を使って通信して
得られた勉強会イベントデータを一覧画面に表示する部分の実装について書きました。

SwiftUI での実装をしていてシンプルな画面の View を作るのは楽だけど
データが絡むと途端に難しく感じました。
プロパティラッパなど理解しながら実際にコードを使っていかないとなと。

ご覧いただきありがとうございました。
ここはこうしたほうがいい，ここはちょっと違うなどありましたらご教示いただければ幸いです。

[^1]: https://connpass.com/about/api/