Help us understand the problem. What is going on with this article?

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

はじめに

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

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

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

ObservableObject と ObservedObject

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

ObservableObject

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

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

@Published

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

@ObservedObject

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

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

今回作るサンプルアプリ

実装画面

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

  • 勉強会のイベントリスト画面
  • イベント詳細画面(のつもり)
  • 取得したイベントURLを開く WebView(SafariViewController)
top detail safari
topListView.png detailView.png safariView.png

実装環境

  • macOS Catalina 10.15
  • Xcode 11.1
  • Swift 5

サンプルプロジェクト

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

QR_Code_1573463583.png

今回使用するAPI

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

リクエストするURL

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

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

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

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

{
  "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 を受けるモデルを作成します。

タップしてモデルのコードを見る
StudyGroup.swift
import Foundation

struct StudyGroup: Decodable {
    var events: [Event]
}

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

タップしてイベントモデルのコードを見る
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"
    }
}

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

タップしてモックデータのコードを見る
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: "株式会社ゆめみ")
]

実装

事前準備

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

イベントリスト画面

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

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一覧"))
        }
    }
}

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

topListView.png

セルのビュー

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

タップしてセルのビューのコードを見る
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])
    }
}

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

rowView.png

本実装

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

タップして通信クラスのコードを見る
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()
    }
}

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

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 のプロパティに変更します。

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

おまけ ~MVVM化~

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

タップして修正した通信クラスのコードを見る
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()
    }
}

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

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

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 を使うようにします。

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 を作るのは楽だけど
データが絡むと途端に難しく感じました。
プロパティラッパなど理解しながら実際にコードを使っていかないとなと。

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

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした