はじめに
以前 SwiftUI の勉強会ネタとして connpass の API を利用して,
YUMEMI.swift という勉強会を検索して検索結果をリストに表示して,
詳細画面にマップや勉強会の内容などを表示するサンプルアプリを SwiftUI で作りました。
あれからだいぶ経ったので少し機能追加,リファクタリングを今回やってみようと思います。
だいぶ開発しやすくなったね,と感じることができるかが気になるところです。
アプリの詳細
詳しくは過去の記事をご覧ください。
API
connpass の API で勉強会検索します。
エンドポイントは下記のままです。
https://connpass.com/api/v1/event/?keyword=YUMEMI.swift
アプリ構成
大きく 3 画面で今回手を入れるのはリスト画面と詳細画面になります。
- リスト画面: API を叩いて取得できた勉強会たちをリスト表示
- 詳細画面: 各勉強会の詳細情報を表示
- イベントページ: Web で勉強会ページを表示
リスト画面 | 詳細画面 |
---|---|
開発環境
- 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 部分を変更すればいいですね。
この実装のPR
deprecated になった API や新しい View 周りの変更
小さい変更もあるので適切に読み飛ばしてください。
復習がてら書いていきます。
SwiftUI の辛いところは毎年新しい API 出ました!
おおお,すげ〜!いいじゃん!
でも過去のバージョンのサポートがなぁというところなので
その辺り下位互換みたいなのがなんとかなればなぁと度々思います😇
Loading 時のグルグル
iOS 14 から ProgressView
が利用可能になりました。
UIKit での UIActivityIndicatorView
にあたるものです。
種類もゲージやプログレスバー形式も指定できたり,
グルグルに加えてテキストも出せたり使い勝手が良いです。
前回までは,iOS 13 / 14 の処理を分けて,直接 List
の View に
ZStack
を使って表示させていましたが,
今回は専用のモディファイアを作ってコードの可読性を上げてみます。
実装前
List
の下のコード部分を書き換える感じになります。
実装前のコード
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 の上に表示できるように書きます。
ローディング中かどうかを判定するフラグを引数に持って出し分けする形になります。
/// ローディング画面を出す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 側の実装を書き直します。だいぶスッキリできました。
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 で使えなさそう)
実装前
実装前のコードは下記の通りで,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
に準拠させます。
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
関数をコールして生成しています。
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
の値を変更します。
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)
+ }
}
}
これで実装完了です。
会場あり | 会場なし |
---|---|
navigationBarTitle のモディファイア書き換え
navigationBarTitle
モディファイアは既に deprecated になっています。
代わりに navigationTitle
と navigationBarTitleDisplayMode
というモディファイアたちを使うように変更します。
リスト画面は LargeTitle
,詳細画面は inline
で表示させたいので
下記のように書き換えします。
// リスト画面
- .navigationBarTitle(Text("YUMEMI.swift一覧"))
+ .navigationTitle("YUMEMI.swift一覧")
+ .navigationBarTitleDisplayMode(.large)
// 詳細画面(タイトルも変更)
- .navigationBarTitle("Event Detail", displayMode: .inline)
+ .navigationTitle("勉強会詳細")
+ .navigationBarTitleDisplayMode(.inline)
想定通りの表示なりました。
リスト画面 | 詳細画面 |
---|---|
NavigationStack の利用
既に NavigationView
が deprecated になっていて,
iOS 16 からは NavigationStack
を代わりに使います。
このアプリでは、検索結果の勉強会一覧画面から詳細画面に遷移する際に
NavigationLink
を使って,いわゆるプッシュ遷移を行っています。
NavigaitonView
を NavigationStack
に移行してみます。
公式に移行ガイドも用意されている模様です。
実装前
NavigationLink
をラップしたセルをタップしたら,
詳細画面に遷移するように実装していました。
実装前のコード
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)
}
}
}
修正
勉強会の情報を展開するモデルの Event
を Hashable
に準拠させます。
NavigationView
を NavigationStack
に書き換えて,
NavigationLink
の destination を
navigationDestination
モディファイアに変更した感じですね。
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 を使ってみたいと思います。
実装前
URLSession
使って通信して,クロージャで結果を返しています。
この部分を async/await 使って書き換えてみます。
実装前のコード
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()
}
}
修正
エラーハンドリングもサボっていたので簡易ながら追加します。
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 側に通知されて勉強会リストが表示されます。
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
でコールするようにします。
struct TopListView: View {
@StateObject private var topListVM = TopListViewModel()
var body: some View {
NavigationStack {
// 省略
}
+ .onAppear {
+ topListVM.fetchEventData()
+ }
}
}
最後に簡単にエラーハンドリングだけしておきます。
まずは ViewModel 側でエラーを View に通知できるように実装します。
方法はいくつかあると思いますが,エラーがあったらフラグを立ててアラート表示にします。
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 から新しいものになっていますね。
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 が追加されたので移行しました。
最終的な動作
変わり映えしないのが残念ですが,リファクタリング完了です。
他にもたくさん対応できそうなことあるけど今回はここまでにします😖
おわりに
今回は過去に作った SwiftUI のアプリを iOS 16 以上のサポートに変更して,
新しい API などを復習しながらリファクタリングと少し機能追加を行いました。
気持ちよく実装できるのがいいところでした。
業務では扱えない分野や技術とかあると思いますが,
そこはうまく個人開発でやってみるとかでカバー(&アウトプット)できたら良いですね。
今年は運の良いことに個人開発に加えて,
春から SwiftUI の案件に従事出来て色々勉強になりました。
(でも割と忙しかった(^^;;)
(そのため)アウトプットはイマイチだったので来年は頑張りたい。
ご覧いただきありがとうございました。
もっとこうした方が良くなる等ありましたらご教示いただけたら嬉しいです。