はじめに
こちらは個人開発アプリができるまで by am10 Advent Calendar 2024の 12 日目の記事です。
12 日目はエラー時のアラート表示や通信時のローディング表示など細かい調整についてです。
エラー時のアラート表示
サーバーからの家計簿データ取得失敗時などにアラートを表示する処理を追加します。
まずは表示するメッセージから実装していきます。下記のように各種 Error を修正して localizedDescription でエラーメッセージを取得できるようにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
extension APIError: LocalizedError { /// アラートで画面に表示する用のメッセージ var errorDescription: String? { switch self { case .network: return "通信に失敗しました。インターネット接続をご確認のうえ、再度お試しください。" case .server, .invalidJSON: return "通信に失敗しました。サーバーに問題があるためしばらく時間をおいて再試行してください。" } } } extension FeliCaSessionError: LocalizedError { /// アラートで画面に表示する用のメッセージ var errorDescription: String? { switch self { case .invalid: return "こちらの端末ではご利用できません。" case .notFound, .notConnected, .invalidService, .invalidHistories: return "こちらのカードは読み取ることができません。" } } } |
これで error.localizedDescription でメッセージが取得できるようになりました。
次に AlertEntity.swift ファイルを作成し下記のように実装します。
1 2 3 4 5 6 7 8 9 10 11 |
import Foundation /// アラート表示用 struct AlertEntitiy { /// アラートのタイトル let title: String /// アラートのメッセージ let message: String /// OK押下時の処理 var okAction: (() -> Void)? = nil } |
これで各 View に下記のように処理を追加すればアラートを表示できるようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
@State private var currentAlert: AlertEntitiy? @State private var isAlertShown = false Button("ぴよ") { showAlert(.init(title: "ほげ", message: "ふが", okAction: { print("ふー") })) }.alert( currentAlert?.title ?? "", isPresented: $isAlertShown, presenting: currentAlert ) { entity in Button("OK") { entity.okAction?() } } message: { entity in Text(entity.message) } func showAlert(_ alert: AlertEntitiy) { currentAlert = alert isAlertShown = true } |
YearView, MonthView のデータ取得処理と RegisterView の登録処理と交通系 IC カード読み取り処理の失敗時の処理に下記を追加すればエラー時のアラート表示は完成です。
1 |
showAlert(.init(title: "エラー", message: error.localizedDescription)) |
登録完了時のアラート表示
現状では登録完了時に登録画面が閉じるだけで登録完了ができたのかよくわからないので登録完了時もアラートを表示するようにします。
下記のように登録処理の成功時にアラート表示を追加するだけです。
1 2 3 4 5 |
try await register() // ここ追加 showAlert(.init(title: "", message: "登録が完了しました。", okAction: { dismiss() })) |
バリデーション
次に家計簿データ登録時にバリデーションを追加します。
ItemValidationError.swift ファイルを作成し下記のように実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
import Foundation protocol ItemValidationError { /// メッセージ var message: String { get } /// 項目内での優先度 var localPriority: Int { get } /// 項目間での優先度 var globalPriority: Int { get } } /// 品名のバリデーションエラー enum ItemNameValidationError: ItemValidationError { /// 入力なし case empty /// 長さ超過 case length var message: String { switch self { case .empty: return "品名を入力してください。" case .length: return "品名は30文字以内にしてください。" } } var localPriority: Int { switch self { case .empty: return 500 case .length: return 100 } } var globalPriority: Int { return 1 } } /// 金額のバリデーションエラー enum ItemPriceValidationError: ItemValidationError { /// 入力なし case empty var message: String { switch self { case .empty: return "金額を入力してください。" } } var localPriority: Int { switch self { case .empty: return 0 } } var globalPriority: Int { return 0 } } |
ItemValidator.swift ファイルを作成し下記のように実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
import Foundation struct ItemValidator { /// 家計簿データのバリデーション /// - Parameter item: 対象の家計簿データ /// - Returns: エラーメッセージ、nilの場合はエラーなし func validate(_ item: Item) -> String? { var results = [ItemValidationError]() // 必要であれば優先度の最も高いものだけを使う(localPriorityで降順にしたfirst) results.append(contentsOf: validateName(item.name)) results.append(contentsOf: validatePrice(item.price)) // 優先度でソート results.sort { if $0.globalPriority != $1.globalPriority { return $0.globalPriority > $1.globalPriority } return $0.localPriority > $1.localPriority } // 必要であれば優先度の最も高いresults.firstだけを使う return formatMessage(results) } private func formatMessage(_ errors: [ItemValidationError]) -> String? { if errors.isEmpty { return nil } return errors.map { $0.message }.joined(separator: "\n") } private func validateName(_ name: String) -> [ItemNameValidationError] { var results = [ItemNameValidationError]() if name.isEmpty { results.append(.empty) } return results } private func validatePrice(_ price: Int) -> [ItemPriceValidationError] { var results = [ItemPriceValidationError]() if price == 0 { results.append(.empty) } return results } } |
ポイントは localPriority と globalPriority です。複数エラーがある場合にすべてのエラーを表示するのか 1 つだけ表示するのか決めるために使います。
globalPriority は name や price など項目ごとに設定し、localPriority は empty や length など項目内のエラーごとに設定します。
validate の return の処理を下記のように修正すれば最も優先度の高いもののみ表示できます。
1 2 3 4 |
if let first = results.first { return formatMessage([first]) } return nil |
validate の append の処理を下記のように修正すれば項目ごとに最も優先度の高いものを 1 つずつ表示できます。
1 2 3 4 5 6 |
if first = validateName(item.name).sorted(by: { $0.localPriority > $1.localPriority }).first { results.append(first) } if first = validatePrice(item.price).sorted(by: { $0.localPriority > $1.localPriority }).first { results.append(first) } |
RegisterView の追加・更新ボタンの処理を下記のように修正してやればバリデーション処理は完成です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
Button(itemIDSelection == nil ? "追加" : "更新") { focusedField = nil if let message = ItemValidator().validate(item) { showAlert(.init(title: "エラー", message: message)) return } if itemIDSelection == nil { // 追加の場合 items.append(makeItem()) } else { // 更新の場合 if let index = items.firstIndex(where: { $0.id == itemIDSelection }) { items[index] = makeItem() } itemIDSelection = nil } clearItem() } // ついでに登録ボタン押下時もチェック追加 if items.isEmpty { showAlert(.init(title: "エラー", message: "登録するデータを追加してください。")) return } |
ItemValidator は思いつきで作ってみたので使いやすいかはわかりませんが何か問題があれば都度修正したいと思います。
ローディング
次にデータ取得などの通信時にローディングを表示します。
現状だとデータ取得・登録処理にそこそこ時間がかかるので待ち時間も退屈しないようにしたいと思います(おそらくスプレッドシートの読み書き処理が遅いと思うのですが改善方法がわかりません)。
まずはユーザーを退屈させないための画像 1 ~ 8 を追加します。
次に LoadingView.swift を作成し下記のように実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
import SwiftUI import UIKit enum LoadingState { /// 何もしていない case idle /// ロード中(message: ロード中に表示するメッセージ) case loading(message: String) } struct LoadingView: UIViewControllerRepresentable { let isLoading: Bool func makeUIViewController(context: Context) -> LoadingViewController { return LoadingViewController() } func updateUIViewController(_ uiViewController: LoadingViewController, context: Context) { if isLoading { uiViewController.start() } } } final class LoadingViewController: UIViewController { var timer: Timer? private let imageSize = CGSize(width: 80, height: 80) override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .init(red: 255/255, green: 251/255, blue: 169/255, alpha: 1.0) } func start() { view.subviews.forEach { $0.removeFromSuperview() } timer?.invalidate() timer = Timer .scheduledTimer( withTimeInterval: 1.0, repeats: true ) { _ in Task { @MainActor in self.addImage(at: self.randomPosition()) } } } private func addImage(at position: CGPoint) { let image = UIImageView(frame: .init(origin: position, size: imageSize)) view.addSubview(image) image.animationImages = Array(1...7).compactMap { UIImage(named: "\($0)") } image.animationDuration = 4 image.animationRepeatCount = 1 image.image = UIImage(named: "7") image.startAnimating() DispatchQueue.main.asyncAfter(deadline: .now() + 4) { image.stopAnimating() image.animationImages = ["7", "8"].compactMap { UIImage(named: $0) } image.animationDuration = 0.5 image.animationRepeatCount = 0 image.startAnimating() } } private func randomPosition() -> CGPoint { let x = CGFloat.random(in: 0...view.frame.width - imageSize.width) let y = CGFloat.random(in: view.safeAreaInsets.top...view.frame.height - imageSize.height) return .init(x: x, y: y) } } |
あとは LoadingView を各画面で表示できるようにしてやります。
まずは YearView と MonthView です。それぞれ下記を追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
@Binding var loadingState: LoadingState // データ取得処理 Button("データ取得") { Task { // これ追加 loadingState = .loading(message: "データ取得中") do { // 取得処理 // これ追加 loadingState = .idle } catch let error // これ追加 loadingState = .idle{ // エラー時の処理 } } } |
ContentView を下記のように修正してやるとデータ取得時にローディング画面を表示できます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// 追加 @State private var loadingState = LoadingState.idle // loadingStateを渡すよう修正 YearView(year: $year, loadingState: $loadingState) // loadingStateを渡すよう修正 MonthView(year: $year, month: $month, loadingState: $loadingState) // ZStackの一番下に追加 if case .loading(let message) = loadingState { LoadingView(isLoading: true) Text(message).foregroundColor(Color.blue) } |
RegisterView も下記のように修正し登録時にローディング画面を表示するようにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
// 追加 @State private var loadingState = LoadingState.idle // ルートをZStackにする ZStack { // 一番下煮追加 if case .loading(let message) = loadingState { LoadingView(isLoading: true) Text(message).foregroundColor(Color.blue) } } // 登録処理修正 Task { // これ追加 loadingState = .loading(message: "データ登録中") do { // 登録処理 // これ追加 loadingState = .idle // 完了アラート表示処理 } catch let error { // これ追加 loadingState = .idle // エラー時の処理 } } |
これでローディングの表示も完成です。こんな感じです(みんな大好き S バイマン)。
おわりに
これでアプリの機能としてはほぼ完成です!!
明日はローカライズ対応をします。
コメント