LoginSignup
17
9

More than 1 year has passed since last update.

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

Posted at

はじめに

以前 SwiftUI の勉強会ネタとして connpass の API を利用して,
YUMEMI.swift という勉強会を検索して検索結果をリストに表示して,
詳細画面にマップや勉強会の内容などを表示するサンプルアプリを SwiftUI で作りました。

あれからだいぶ経ったので少し機能追加,リファクタリングを今回やってみようと思います。
だいぶ開発しやすくなったね,と感じることができるかが気になるところです。

アプリの詳細

詳しくは過去の記事をご覧ください。

API

connpass の API で勉強会検索します。
エンドポイントは下記のままです。
https://connpass.com/api/v1/event/?keyword=YUMEMI.swift

アプリ構成

大きく 3 画面で今回手を入れるのはリスト画面と詳細画面になります。

  • リスト画面: API を叩いて取得できた勉強会たちをリスト表示
  • 詳細画面: 各勉強会の詳細情報を表示
  • イベントページ: Web で勉強会ページを表示
リスト画面 詳細画面
スクリーンショット 2022-12-21 0.14.54.png スクリーンショット 2022-12-21 0.14.59.png

開発環境

  • Xcode 14.2
  • macOS 12.5.1
  • M2 MacBook Air
  • iOS 16.0 以上
  • アーキテクチャ:シンプルな MVVM

サンプルコードは下記のリポジトリになります。

今回は,GitHub Projects を利用して課題管理を行いました。
興味ある方はご覧ください。
新しい方の GitHub Projects は初めて使ったのですが
Classic の方がカードの表示される情報が多くてカンバン表示は見やすいと思う・・・

今回の対応内容

  • iOS 16 以上のサポート
  • deprecated になったAPIや新しいView周りの変更
    • Loading 時のグルグル
    • MAP の変更
    • navigationBarTitle のモディファイア書き換え
    • NavigationStack の利用
  • async/await を利用
  • SwiftUI App なライフサイクルに移行

iOS 16 以上のサポート

今回のアップデートで,使ってみたい機能があったため,
最新OSのみのバージョンサポートにしました。

最近担当した案件で iOS 14 以上のサポートで開発しましたが,
結構不具合出て SwiftUI で開発するなら iOS 14からだよね〜と
思ってた過去の自分に文句言ってあげたい。

個々のアプリの UX やユーザ数,保守のなど色々な観点にはなりますが,
可能ならばできるだけ過去のOSはサポート切った方が当然ながら良いです。

こういう,仕様はオレ!みたいな個人開発はその点割り切れるのでいいですね。

対応自体は簡単で iOS Deployment Target 部分を変更すればいいですね。
スクリーンショット 2022-12-18 23.37.19.png
この実装のPR

deprecated になった API や新しい View 周りの変更

小さい変更もあるので適切に読み飛ばしてください。
復習がてら書いていきます。

SwiftUI の辛いところは毎年新しい API 出ました!
おおお,すげ〜!いいじゃん!
でも過去のバージョンのサポートがなぁというところなので
その辺り下位互換みたいなのがなんとかなればなぁと度々思います😇

Loading 時のグルグル

iOS 14 から ProgressView が利用可能になりました。
UIKit での UIActivityIndicatorView にあたるものです。
種類もゲージやプログレスバー形式も指定できたり,
グルグルに加えてテキストも出せたり使い勝手が良いです。

前回までは,iOS 13 / 14 の処理を分けて,直接 List の View に
ZStack を使って表示させていましたが,
今回は専用のモディファイアを作ってコードの可読性を上げてみます。

この実装のPR

実装前

List の下のコード部分を書き換える感じになります。

実装前のコード
TopListView.swift
struct TopListView: View {
    @ObservedObject private var topListVM = TopListViewModel()

    var body: some View {
        NavigationView {
            ZStack {
                List(topListVM.eventData) { event in
                    NavigationLink(destination: EventDetailView(eventData: event)) {
                        EventRowView(eventData: event)
                    }
                }
                if self.topListVM.isShowIndicator {
                    if #available(iOS 14.0, *) {
                        AnyView(ProgressView("Loading..."))
                    } else {
                        LoadingView()
                    }
                }
            }
            .navigationBarTitle(Text("YUMEMI.swift一覧"))
        }
    }
}

修正

LoadingViewModifier.swift を実装します。
content がモディファイアを実装する View にあたり,
その View の上に表示できるように書きます。

ローディング中かどうかを判定するフラグを引数に持って出し分けする形になります。

LoadingViewModifier.swift
/// ローディング画面を出すViewModifier
struct LoadingViewModifier: ViewModifier {
    var isRefreshing: Bool

    func body(content: Content) -> some View {
        ZStack {
            content
                .allowsHitTesting(!isRefreshing)

            if isRefreshing {
                ProgressView {
                    Text("Loading...")
                }
            }
        }
    }
}

さらに View 側でのコードの可読性を上げるために関数化しておきます。

extension View {
    /// 通信中にProgressViewを表示
    /// - Parameters:
    ///   - isRefreshing: 通信中か
    /// - Returns: ローディング画面
    func loading(isRefreshing: Bool, safeAreaEdges: Edge.Set = []) -> some View {
        modifier(LoadingViewModifier(isRefreshing: isRefreshing))
    }
}

View 側の実装を書き直します。だいぶスッキリできました。

TopListView.swift
struct TopListView: View {
    @StateObject private var topListVM = TopListViewModel()

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

MAP の変更

iOS 14 から MAP が SwiftUI で利用できるようになりました。
今までは UIKit を使う形で実装していました。

今回は,SwiftUI で新しく使えるようになった Map を使うように書き直します。
このアプリでは,Map の中央を勉強会の開催地として,
アノテーションを表示するだけのものになります。

もっと複雑な要件がある場合は UIKit の方を使った方が実装しやすいと思います。
今回 POI(Point of Interests) の実装は削除します。(まだ SwiftUI で使えなさそう)

この実装のPR

実装前

実装前のコードは下記の通りで,UIKit の MKMapView を使っています。
勉強会のデータを親ビューから受け取って地図上にアノテーションを付与しています。
この Map 部分を書き直してみます。

実装前のコード
import SwiftUI
import MapKit

struct MapView: UIViewRepresentable {

    let eventData: Event!
    @Binding var zoomValue: CLLocationDegrees

    func makeUIView(context: Context) -> MKMapView {
        let mapView = MKMapView(frame: .zero)
        return mapView
    }

    // Required
    func updateUIView(_ uiView: MKMapView, context: UIViewRepresentableContext<MapView>) {
        // If address is オンライン, the location data are nil.
        guard let lat = eventData.lat, let lon = eventData.lon else {
            return
        }
        let center = CLLocationCoordinate2DMake(Double(lat)!, Double(lon)!)
        let span = MKCoordinateSpan(latitudeDelta: zoomValue, longitudeDelta: zoomValue)
        let region = MKCoordinateRegion(center: center, span: span)
        uiView.setRegion(region, animated: true)
        uiView.showsUserLocation = true
        uiView.userTrackingMode = .follow

        // POI Filtering
        let category: [MKPointOfInterestCategory] = [.parking, .publicTransport]
        let filter = MKPointOfInterestFilter(including: category)
        uiView.pointOfInterestFilter = filter

        // Put Annotaion on event place
        let annotation = MKPointAnnotation()
        annotation.coordinate = center
        annotation.title = "ココ!"
        annotation.subtitle = eventData.place
        uiView.addAnnotation(annotation)
    }
}

修正

アノテーション情報用の構造体を追加します。
Identifiable に準拠させます。

PinItem.swift
import Foundation
import MapKit

struct PinItem: Identifiable {
    let id = UUID()
    let coordinate: CLLocationCoordinate2D
}

位置情報だけ欲しいのでリスト画面から渡ってくる勉強会の情報から
緯度経度の情報を抜き出して [PinItem] で返せるように関数を実装します。

/// アノテーション用のデータを生成
private func generatePinItem() -> [PinItem] {
    guard let latitude = eventData.lat,
          let latValue = Double(latitude),
          let longitude = eventData.lon,
          let lonValue = Double(longitude) else {
        return []
    }
    return [PinItem(coordinate: CLLocationCoordinate2D(latitude: latValue, longitude: lonValue))]
}

NewMapView を新規で追加して新しい Map の実装を行います。
Map 生成時に地図の設定やアノテーション情報を設定するようです。

便宜上,Map の中央座標は初期値で東京駅にします。
表示時に勉強会の会場の緯度経度の情報をマップの中央座標に更新します。
onAppear 内で setTargetRegion 関数をコールして生成しています。

NewMapView.swift
import SwiftUI
import MapKit // 必要

struct NewMapView: View {
    // リスト画面から渡ってくる勉強会情報
    let eventData: Event

    @State private var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 35.6816005869028, longitude: 139.76595878344898), span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01))

    var body: some View {
        Map(
            coordinateRegion: $region,
            annotationItems: generatePinItem()) { item in
                MapMarker(coordinate: item.coordinate)
            }
        .onAppear {
            setTargetRegion()
        }
    }
}

extension NewMapView {
    /// アノテーション用のデータを生成
    private func generatePinItem() -> [PinItem] {
        guard let latitude = eventData.lat,
              let latValue = Double(latitude),
              let longitude = eventData.lon,
              let lonValue = Double(longitude) else {
            return []
        }
        return [PinItem(coordinate: CLLocationCoordinate2D(latitude: latValue, longitude: lonValue))]
    }

    /// 表示時にマップの中央を会場の場所にする
    private func setTargetRegion() {
        guard let latitude = eventData.lat,
              let latValue = Double(latitude),
              let longitude = eventData.lon,
              let lonValue = Double(longitude) else {
            // オンラインなどで緯度経度の情報がnilの場合広域にしておく
            region.span = MKCoordinateSpan(latitudeDelta: 30, longitudeDelta: 30)
            return
        }
        // マップの中央を会場に
        region.center = CLLocationCoordinate2D(latitude: latValue, longitude: lonValue)
    }
}

親ビューで + - ボタンを表示していて,
このボタンたちをそれぞれタップすることで
マップを拡大縮小できるようにしていました。

よって + - ボタンタップ時に変わる拡大,縮小の値の変化を
onChange で検知して,region.span の値を変更します。

NewMapView.swift
import SwiftUI
import MapKit // 必要

struct NewMapView: View {
    // リスト画面から渡ってくる勉強会情報
    let eventData: Event
+   // 詳細画面のマップの右下の+ - ボタンで変わるズームの値
+   @Binding var zoomValue: CLLocationDegrees

    @State private var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 35.6816005869028, longitude: 139.76595878344898), span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01))

    var body: some View {
        Map(
            coordinateRegion: $region,
            annotationItems: generatePinItem()) { item in
                MapMarker(coordinate: item.coordinate)
            }
        .onAppear {
            setTargetRegion()
        }
+       .onChange(of: zoomValue) { newValue in
+           // + - ボタンでズームの値が変わるのでその変化を検知してマップの拡大縮小を行う
+           region.span = MKCoordinateSpan(latitudeDelta: zoomValue, longitudeDelta: zoomValue)
+       }
    }
}

これで実装完了です。

会場あり 会場なし
qiita_221221.gif

navigationBarTitle のモディファイア書き換え

navigationBarTitle モディファイアは既に deprecated になっています。
代わりに navigationTitlenavigationBarTitleDisplayMode
というモディファイアたちを使うように変更します。

リスト画面は LargeTitle,詳細画面は inline で表示させたいので
下記のように書き換えします。

// リスト画面
- .navigationBarTitle(Text("YUMEMI.swift一覧"))
+ .navigationTitle("YUMEMI.swift一覧")
+ .navigationBarTitleDisplayMode(.large)

// 詳細画面(タイトルも変更)
- .navigationBarTitle("Event Detail", displayMode: .inline)
+ .navigationTitle("勉強会詳細")
+ .navigationBarTitleDisplayMode(.inline)

想定通りの表示なりました。

リスト画面 詳細画面
スクリーンショット 2022-12-19 10.52.03.png スクリーンショット 2022-12-19 10.52.08.png

この実装のPR

NavigationStack の利用

既に NavigationView が deprecated になっていて,
iOS 16 からは NavigationStack を代わりに使います。

このアプリでは、検索結果の勉強会一覧画面から詳細画面に遷移する際に
NavigationLink を使って,いわゆるプッシュ遷移を行っています。
NavigaitonViewNavigationStack に移行してみます。

公式に移行ガイドも用意されている模様です。

この実装のPR

実装前

NavigationLink をラップしたセルをタップしたら,
詳細画面に遷移するように実装していました。

実装前のコード
ToPListView.swift
struct TopListView: View {
    @StateObject private var topListVM = TopListViewModel()

    var body: some View {
        NavigationStack {
            List(topListVM.eventData) { event in
                NavigationLink(value: event) {
                    EventRowView(eventData: event)
                }
            }
            .navigationDestination(for: Event.self) { event in
                EventDetailView(eventData: event)
            }
            .navigationTitle("YUMEMI.swift一覧")
            .navigationBarTitleDisplayMode(.large)
            .loading(isRefreshing: topListVM.isShowIndicator)
        }
    }
}

修正

勉強会の情報を展開するモデルの EventHashable に準拠させます。

NavigationViewNavigationStack に書き換えて,
NavigationLink の destination を
navigationDestination モディファイアに変更した感じですね。

TopListView.swift
struct TopListView: View {
    @StateObject private var topListVM = TopListViewModel()

    var body: some View {
+       NavigationStack {
+           List(topListVM.eventData) { event in
+               NavigationLink(value: event) {
+                   EventRowView(eventData: event)
+               }
+           }
+           .navigationDestination(for: Event.self) { event in
+               EventDetailView(eventData: event)
+           }
-       NavigationView {
-           List(topListVM.eventData) { event in
-               NavigationLink(destination: EventDetailView(eventData: event)) {
-                   EventRowView(eventData: event)
-               }
-           }
            .navigationTitle("YUMEMI.swift一覧")
            .navigationBarTitleDisplayMode(.large)
            .loading(isRefreshing: topListVM.isShowIndicator)
        }
    }
}

実装してみたけど・・・
可読性は少し上がった程度であまりメリットない気がしますね。

セルの種類によって,遷移先を変えたり使いやすくなってるようなので
path とかも交えて,またの機会に他のアプリで使ってみたい。

async/await を使ってみる

Swift 5.5 から async/await が利用可能になりました。
iOS 13 以上で利用可能となかなか良きでした。(不具合も聞くけど😇)
(こういう下位互換を切に望んでいるのですが…)

このアプリでは勉強会情報取得のために API をコールしています。
この通信部分で async/await を使ってみたいと思います。

この実装のPR

実装前

URLSession 使って通信して,クロージャで結果を返しています。
この部分を async/await 使って書き換えてみます。

実装前のコード
StudyGroupEventFetcher.swift
final class StudyGroupEventFetcher {

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

    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()
    }
}

修正

エラーハンドリングもサボっていたので簡易ながら追加します。

APIError
enum APIError: Error {
    /// レスポンスエラー
    case response
    /// JSONパースエラー等
    case jsonDecode
    /// ステータスコードあり
    case statusCode(statusCode: String)
}

extension APIError: LocalizedError {
    var errorDescription: String? {
        switch self {
        case .response:
            return "Response Error"

        case .jsonDecode:
            return "json convert failed in JSONDecoder"

        case .statusCode(let statusCode):
            return "Error! StatuCode: " + String(statusCode)
        }
    }
}

async/await を使って通信周りを書き換えます。
print 文で書いてた各種エラーを throw するようにします。

final class StudyGroupEventFetcher {
    // connpass's event search API
    private let urlLink = "https://connpass.com/api/v1/event/?keyword=YUMEMI.swift&count=100"

    func fetchEventData() async throws -> [Event] {
        let (data, response) = try await URLSession.shared.data(from: URL(string: urlLink)!)

        guard let httpResponse = response as? HTTPURLResponse else {
            throw APIError.response
        }

        switch httpResponse.statusCode {
        case 200:
            do {
                let searchedResultData = try JSONDecoder().decode(StudyGroup.self, from: data)
                return searchedResultData.events.reversed()

            } catch {
                throw APIError.jsonDecode
            }

        default:
            throw APIError.statusCode(statusCode: httpResponse.statusCode.description)
        }
    }
}

続いて ViewModel 側で実装した関数をコールする処理を書きます。
取得できたら eventData に値が入るので変更があったら
View 側に通知されて勉強会リストが表示されます。

TopListViewModel.swift
final class TopListViewModel: ObservableObject {
    @Published var eventData: [Event] = []
    @Published var isShowIndicator = false

    private let fetcher = StudyGroupEventFetcher()

    /// 勉強会データをAPIを叩いて取得(async/await版)
    func fetchEventData() {
        Task { @MainActor in
            isShowIndicator = true
            defer {
                isShowIndicator = false
            }

            do {
                eventData = try await fetcher.fetchEventData()

            } catch {
                // TODO: エラーハンドリング
            }
        }
    }
}

View側で任意のタイミングで ViewModel のデータ取得メソッドをコールします。
例えば,今回は画面表示時にしようと思うので,onAppear でコールするようにします。

TopListView.swift
struct TopListView: View {
    @StateObject private var topListVM = TopListViewModel()

    var body: some View {
        NavigationStack {
            // 省略
        }
+       .onAppear {
+           topListVM.fetchEventData()
+       }
    }
}

最後に簡単にエラーハンドリングだけしておきます。
まずは ViewModel 側でエラーを View に通知できるように実装します。
方法はいくつかあると思いますが,エラーがあったらフラグを立ててアラート表示にします。

TopListViewModel.swift
final class TopListViewModel: ObservableObject {
    @Published var eventData: [Event] = []
    @Published var isShowIndicator = false
+   @Published var error: APIError?
+   @Published var isShowAlert = false

    private let fetcher = StudyGroupEventFetcher()

    /// 勉強会データをAPIを叩いて取得(async/await版)
    func fetchEventData() {
        Task { @MainActor in
            isShowIndicator = true
            defer {
                isShowIndicator = false
            }

            do {
                eventData = try await fetcher.fetchEventData()

            } catch {
+               // APIErrorを拾いたい
+               if let apiError = error as? APIError {
+                   self.error = apiError
+                   isShowAlert = true
+               } else {
+                   // 🤔
+               }
            }
        }
    }
}

View 側に alert モディファイアを使ってアラートダイアログ表示できるようにします。
そういえば alert も iOS 15 から新しいものになっていますね。

TopListView.swift
struct TopListView: View {
    @StateObject private var topListVM = TopListViewModel()

    var body: some View {
        NavigationStack {
            // 省略
        }
        .onAppear {
            topListVM.fetchEventData()
        }
+       .alert(isPresented: $topListVM.isShowAlert, error: topListVM.error) { _ in
+           Button("OK", action: {})
+       } message: { error in
+           Text(error.errorDescription ?? "なぜかnilみたいね")
+       }
    }
}

これで例えば API のエンドポイントいじると・・・
404 のステータスコードのエラーが表示されました。
データないよー的な No Image とかも表示させたくなるけど今回は省きます><

その他

SwiftUI App のライフサイクルに変更

SwiftUI 初期は UIKit App Delegate でしたが,
iOS 14 からアプリのライフサイクルに SwiftUI App が追加されたので移行しました。

この実装のPR

最終的な動作

変わり映えしないのが残念ですが,リファクタリング完了です。

RPReplay_Final1671627103.gif

他にもたくさん対応できそうなことあるけど今回はここまでにします😖

おわりに

今回は過去に作った SwiftUI のアプリを iOS 16 以上のサポートに変更して,
新しい API などを復習しながらリファクタリングと少し機能追加を行いました。
気持ちよく実装できるのがいいところでした。

業務では扱えない分野や技術とかあると思いますが,
そこはうまく個人開発でやってみるとかでカバー(&アウトプット)できたら良いですね。

今年は運の良いことに個人開発に加えて,
春から SwiftUI の案件に従事出来て色々勉強になりました。
(でも割と忙しかった(^^;;)
(そのため)アウトプットはイマイチだったので来年は頑張りたい。

ご覧いただきありがとうございました。
もっとこうした方が良くなる等ありましたらご教示いただけたら嬉しいです。

17
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
9