はじめに
こちらは個人開発アプリができるまで by am10 Advent Calendar 2024の 7 日目の記事です。
7 日目は年画面を作成します。
完成形はこんな感じです。
タブ切替
まずはタブを作成します。YearView.swift ファイルを作成し下記のように year を追加します。
1 |
@Binding var year: String |
ContentView.swift を下記のように修正します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
struct ContentView: View { @State var year: String var body: some View { TabView{ YearView(year: $year) .tabItem { Image(systemName: "y.circle.fill") Text("年") } Text("ここに月画面") .tabItem { Image(systemName: "m.circle.fill") Text("月") } } } } |
ExpenseLogApp の ContentView の生成処理を下記のように修正します。
1 |
ContentView(year: String(Calendar(identifier: .gregorian).component(.year, from: Date()))) |
これでタブ切替は完成です。
年選択
次に年を選択するためのピッカーを作成します。値は現在から 10 年分でいいでしょう。
YearView を下記のように修正します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
struct YearView: View { @Binding var year: String private var years: [String] { let currentYear = Calendar(identifier: .gregorian).component(.year, from: Date()) // 10年分のリスト作成 return (currentYear - 9...currentYear).map { String($0) } } var body: some View { VStack { HStack { Picker("年", selection: $year) { ForEach(years, id: \.self) { Text($0 + "年") } } } } } } |
これで 10 年分の年を選択するピッカーができました。
項目選択
次に項目選択をするピッカーを作成します。
項目を扱いやすくするために Category.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 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 |
import Foundation /// 項目 enum Category { /// 食費 case food(FoodSub?) /// 娯楽費 case entertainment(EntertainmentSub?) /// 未選択(アプリの表示のみだ扱う値) static let unselectedValue = -1 /// 食費 static let foodValue = 0 /// 娯楽費 static let entertainmentValue = 1 static let categoryValues = [ foodValue, entertainmentValue, ] init(categoryValue: Int, categorySubValue: Int?) { switch categoryValue { case Self.foodValue: self = .food(categorySubValue == nil ? nil : .init(rawValue: categorySubValue!)) case Self.entertainmentValue: self = .entertainment(categorySubValue == nil ? nil : .init(rawValue: categorySubValue!)) default: assertionFailure("不正な値!!!!") self = .food(nil) } } } extension Category { // 表示名 var name: String { switch self { case .food: return "食費" case .entertainment: return "娯楽費" } } /// 各項目の数値(サーバーの値と対応) var value: Int { switch self { case .food: return Self.foodValue case .entertainment: return Self.entertainmentValue } } } /// サブ項目 protocol CategorySub: CaseIterable { /// 表示名 var name: String { get } /// 各サブ項目の数値(サーバーの値と対応) var value: Int { get } } // MARK: - 食費 /// 食費サブ項目(0) enum FoodSub: Int { /// 食品 case grocery = 0 /// 飲料 case drink = 1 /// 菓子類 case sweets = 2 /// 栄養補助 case supplement = 3 } extension FoodSub: CategorySub { var name: String { switch self { case .grocery: return "食品" case .drink: return "飲料" case .sweets: return "菓子" case .supplement: return "栄養補助" } } var value: Int { return rawValue } } // MARK: - 娯楽費 /// 娯楽費サブ項目(1) enum EntertainmentSub: Int { /// 嗜好品 case luxury = 0 /// 交通費 case traffic = 1 /// 雑貨 case goods = 2 } extension EntertainmentSub: CategorySub { var name: String { switch self { case .luxury: return "嗜好品" case .traffic: return "交通費" case .goods: return "雑貨" } } var value: Int { return rawValue } } |
項目を追加したい場合はここに記載していきます。
次に CategoryFilterPicker.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 |
import SwiftUI /// 項目ピッカー(サブ項目なし) struct CategoryFilterPicker: View { /// 選択した項目 @Binding var categorySelection: Int private let categoryList = [Category.unselectedValue] + Category.categoryValues var body: some View { Picker("項目", selection: $categorySelection) { ForEach(categoryList, id: \.self) { value in if value == Category.unselectedValue { Text("すべて") .tag(value) } else { Text(Category(categoryValue: value, categorySubValue: nil).name) .tag(value) } } } } } |
YearView に下記を追加して項目ピッカーは完成です。
1 2 3 |
@State private var categorySelection: Int = Category.unselectedValue CategoryFilterPicker(categorySelection: $categorySelection) |
登録ボタン
次に登録画面表示用のフローティングボタンを作成します。
FloatingButton.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 |
import SwiftUI /// フローティングボタン struct FloatingButton: View { /// 押下時の処理 var tappedAction: (() -> Void)? = nil /// ボタンの色 var color: Color = .orange /// 上下左右の余白 var padding: EdgeInsets = .init(top: 0, leading: 0, bottom: 40, trailing: 16) /// アイコンの名前(システムイメージ限定) var imageSystemName: String = "pencil" /// アイコンの色 var imageColor: Color = .white /// アイコンのサイズ var imageFontSize: CGFloat = 24 /// 縦・横の長さ var length: CGFloat = 60 private var cornerRadius: CGFloat { return length/2 } var body: some View { VStack { Spacer() HStack { Spacer() Button(action: { tappedAction?() }, label: { Image(systemName: imageSystemName) .foregroundColor(imageColor) .font(.system(size: imageFontSize)) }) .frame(width: length, height: length) .background(color) .cornerRadius(cornerRadius) .shadow(color: .gray, radius: 3, x: 3, y: 3) .padding(padding) } } } } |
ContentView のルートを ZStack にして下記を追記します。
1 2 3 4 5 6 7 8 |
@State private var isPresented = false /// TabViewの下に追加 FloatingButton() { isPresented = true }.fullScreenCover(isPresented: $isPresented) { Text("ここに登録画面") } |
これで登録画面表示用のフローティングボタンは完成です。
データ取得
async/await に対応
次にデータ取得処理を作成します。API は年と月を指定して月ごとに取得しかないので指定年の 1 ~ 12 月を順次取得しなければなりません。async/await に対応して扱いやすくするために修正します。ここで残念なお知らせです Swift 6 に対応した結果そこそこ修正が必要なようです。。。
ResponseItem.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 |
import Foundation /// サーバーとやり取りするための家計簿データ struct ResponseItem: Sendable { /// ID let id: String /// 購入日 let date: Date /// 品名 let name: String /// 価格 let price: Int /// 項目 let category: Int /// サブ項目 let categorySub: Int? } extension ResponseItem: Codable { enum CodingKeys: CodingKey { case id case date case name case price case category case categorySub } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(String.self, forKey: .id) date = try container.decode(Date.self, forKey: .date) name = try container.decode(String.self, forKey: .name) price = try container.decode(Int.self, forKey: .price) category = try container.decode(Int.self, forKey: .category) categorySub = try? container.decode(Int.self, forKey: .categorySub) } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) try container.encode(date, forKey: .date) try container.encode(name, forKey: .name) try container.encode(price, forKey: .price) try container.encode(category, forKey: .category) try container.encode(categorySub, forKey: .categorySub) } } extension ResponseItem { func convert() -> Item { return .init(id: id, date: date, name: name, price: price, categoryValue: category, categorySubValue: categorySub) } } |
Item を下記のように修正します。
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 |
import Foundation import SwiftData @Model final class Item { /// ID @Attribute(.unique) var id: String /// 購入日 var date: Date /// 品名 var name: String /// 価格 var price: Int /// 項目 var categoryValue: Int /// サブ項目 var categorySubValue: Int? init(id: String, date: Date, name: String, price: Int, categoryValue: Int, categorySubValue: Int?) { self.id = id self.date = date self.name = name self.price = price self.categoryValue = categoryValue self.categorySubValue = categorySubValue } } extension Item { func convert() -> ResponseItem { return .init(id: id, date: date, name: name, price: price, category: categoryValue, categorySub: categorySubValue) } } |
ExpenseLogAPI の register の部分を Item から ResponseItem に修正します。
1 2 |
/// データ登録 case register(items: [ResponseItem]) |
ExpenseLogAPIClient の取得・登録処理の Item を下記のように ResponseItem に修正します。
1 2 |
func fetch(year: String, month: String, completion: @escaping @Sendable (Result<[ResponseItem], APIError>) -> Void) func register(items: [ResponseItem], completion: @escaping @Sendable (Result<[ResponseItem], APIError>) -> Void) |
Session と ExpenseLogAPIClient に下記のように Sendable を追加します。
1 2 |
protocol Session: Sendable final class ExpenseLogAPIClient: Sendable |
ExpenseLogAPIClient に下記を追加します。
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 |
extension ExpenseLogAPIClient { /// 指定の年月の家計簿データ取得処理 /// - Parameters: /// - year: 年(yyyy形式) /// - month: 月(M形式、範囲は1~12) func fetch(year: String, month: String) async throws -> [ResponseItem] { try await withCheckedThrowingContinuation { continuation in fetch(year: year, month: month) { result in switch result { case .success(let items): continuation.resume(returning: items) case .failure(let error): continuation.resume(throwing: error) } } } } /// 家計簿データ登録処理 /// - Parameters: /// - items:登録する家計簿データ func register(items: [ResponseItem]) async throws -> [ResponseItem] { try await withCheckedThrowingContinuation { continuation in register(items: items) { result in switch result { case .success(let items): continuation.resume(returning: items) case .failure(let error): continuation.resume(throwing: error) } } } } } |
これで async/await に対応できました。
サーバーからデータ取得
YearView に下記を追加すればサーバーからのデータ取得は完成です。
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 |
@Environment(\.apiClient) private var apiClient Button("データ取得") { Task { do { let items = try await remoteFetch() print(items.count) } catch let error { print(error) } } } extension YearView { /// サーバーから選択年の1~12月の家計簿データを取得する func remoteFetch() async throws -> [ResponseItem] { return try await withThrowingTaskGroup(of: [ResponseItem]?.self) { group in for month in 1...12 { group.addTask { return try await apiClient.fetch(year: year, month: String(month)) } } var allItems: [ResponseItem] = [] for try await items in group { if let items = items { allItems.append(contentsOf: items) } } return allItems } } } |
ローカルにデータを保存
毎回サーバーとは通信したくないので SwiftData を使って端末内に家計簿データを保存するようにします。
サーバーとの整合性を取るためにデータを取得した場合はその年のローカルのデータを削除してから挿入するようにします。
今回のアプリではデータ取得はそこまで頻繁に行う操作ではない想定なので毎回削除してから挿入します。
Database.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 |
import Foundation import SwiftData extension ModelContext { /// 家計簿データ保存処理 /// - Parameter items: 追加するデータ func addItems(_ items: [ResponseItem]) { items.map { $0.convert() } .forEach { insert($0) } do { try save() } catch let error { print(error) assertionFailure("データ登録でエラー") } } /// 年と項目を指定して家計簿データを取得する /// - Parameters: /// - year: 指定の年(yyyy形式) /// - category: 指定の項目(nilの場合は指定の年のデータをすべて取得) /// - Returns: 指定の年と項目の家計簿データ func fetchItems(year: String, category: Category?) -> [Item] { let calendar = Calendar(identifier: .gregorian) let startDate = DateComponents(calendar: calendar, year: Int(year)!, month: 1, day: 1).date! let endDate = calendar.date(byAdding: DateComponents(month: 1, day: -1), to: DateComponents(calendar: calendar, year: Int(year)!, month: 12).date!)! let filteredItems: [Item]? if let categoryValue = category?.value { filteredItems = try? fetch( FetchDescriptor<Item>( predicate: #Predicate { $0.categoryValue == categoryValue && (startDate...endDate).contains($0.date) } ) ) } else { filteredItems = try? fetch( FetchDescriptor<Item>( predicate: #Predicate { (startDate...endDate).contains($0.date) } ) ) } return filteredItems ?? [] } /// 指定の年の家計簿データを削除する /// - Parameter year: 指定の年(yyyy形式) func removeItems(year: String) { let items = fetchItems(year: year, category: nil) items.forEach { delete($0) } } } |
YearView を下記のように修正します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
// 追加 @Environment(\.modelContext) private var context @State private var items: [Item] = [] // データ取得のところに追加 let items = try await remoteFetch() context.removeItems(year: year) context.addItems(items) localFetch() /// ローカル DB から年と項目を指定してデータを取得する func localFetch() { let category: Category? if categorySelection == Category.unselectedValue { category = nil } else { category = .init(categoryValue: categorySelection, categorySubValue: nil) } items = context.fetchItems(year: year, category: category) } |
これでサーバーから取得したデータを端末に保存できるようになりました。
さらに下記を追加して年ピッカーと項目ピッカーで選択した年と項目の家計簿データを取得するようにします。
1 2 3 4 5 6 7 8 9 10 |
// ルートのVStackに追加 .onChange(of: year) { _, _ in localFetch() } .onChange(of: categorySelection) { _, _ in localFetch() } .onAppear() { localFetch() } |
合計金額表示
YearView に下記を追加して合計金額を表示します。
1 2 3 4 5 6 7 8 |
private var totalPrice: Int { return items.reduce(0) { partialResult, item in partialResult + item.price } } // 年・項目ピッカーのHstackとデータ取得ボタンの間に追加 Text("\(totalPrice)円") |
グラフ表示
横軸を月、縦軸を金額した項目ごとの積立棒グラフを作成するには家計簿データを項目ごとにわけさらにその中で月ごとにわける必要があります。
項目選択時はサブ項目ごとにわけさらにその中で月ごとにわける必要があります。
YearChartFormatter.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 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 |
import Foundation struct YearChartFormatter { /// 各項目(またはサブ項目)の各月の家計簿データの合計金額 struct ChartData { /// ID let id = UUID().uuidString /// 項目 let category: Category /// 各月の家計簿データの合計金額 let monthlyTotalPrices: [MonthlyTotalPrice] } /// 各月の家計簿データの合計金額 struct MonthlyTotalPrice { /// ID let id = UUID().uuidString /// 月(1~12) let month: Int /// 月の合計金額 let price: Int /// 月初の日付(1月なら1/1) let date: Date } /// 任意の年のすべての家計簿データを棒グラフ表示用に整形する /// - Parameter items: 任意の年の家計簿データ /// - Returns: 棒グラフ表示用に整形した家計簿データ func formatAllItems(_ items: [Item]) -> [ChartData] { if items.isEmpty { return [] } // 項目ごとにグルーピング let categoryGrouping = Dictionary( grouping: items, by: { $0.categoryValue } ) let calendar = Calendar(identifier: .gregorian) let year = calendar.component(.year, from: items.first!.date) return categoryGrouping.keys.map { categoryValue in // 月ごとにグルーピング let monthGrouping = Dictionary( grouping: categoryGrouping[categoryValue]!, by: { calendar.component(.month, from: $0.date) } ) return ChartData( category: .init(categoryValue: categoryValue, categorySubValue: nil), monthlyTotalPrices: monthGrouping.keys.map { month in MonthlyTotalPrice( month: month, price: monthGrouping[month]!.reduce(0) { partialResult, item in partialResult + item.price }, date: DateComponents(calendar: calendar, year: year, month: month, day: 1).date! ) }) } } /// 任意の年の任意の項目の家計簿データを棒グラフ表示用に整形する /// - Parameter items: 任意の年の任意の項目の家計簿データ /// - Returns: 棒グラフ表示用に整形した家計簿データ func formatCategoryFilteredItems(_ items: [Item]) -> [ChartData] { if items.isEmpty { return [] } // サブ項目ごとにグルーピング let categorySubGrouping = Dictionary( grouping: items, by: { $0.categorySubValue } ) let calendar = Calendar(identifier: .gregorian) let year = calendar.component(.year, from: items.first!.date) return categorySubGrouping.keys.map { categorySubValue in // 月ごとにグルーピング let monthGrouping = Dictionary( grouping: categorySubGrouping[categorySubValue]!, by: { calendar.component(.month, from: $0.date) } ) return ChartData( category: .init(categoryValue: items.first!.categoryValue, categorySubValue: categorySubValue), monthlyTotalPrices: monthGrouping.keys.map { month in MonthlyTotalPrice( month: month, price: monthGrouping[month]!.reduce(0) { partialResult, item in partialResult + item.price }, date: DateComponents(calendar: calendar, year: year, month: month, day: 1).date! ) }) } } } |
これでグラフ用のデータ整形処理はできました。
次にグラフ表示時の項目とサブ項目の色を下記のように Assets に追加します。
CategorySub に下記のように color を追加してサブ項目に色を追加します。
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 |
/// グラフ表示時の色 var color: UIColor { get } // FoodSub var color: UIColor { switch self { case .grocery: return .grocery case .drink: return .drink case .sweets: return .sweets case .supplement: return .supplement } } // EntertainmentSub var color: UIColor { switch self { case .luxury: return .luxury case .traffic: return .traffic case .goods: return .goods } } |
Category に下記を追加します。
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 |
/// グラフ表示用 extension Category { /// グラフ表示時の色 var color: UIColor { switch self { case .food: return .food case .entertainment: return .entertainment } } /// サブ項目 var sub: (any CategorySub)? { switch self { case .food(let foodSub): return foodSub case .entertainment(let entertainmentSub): return entertainmentSub } } /// サブ項目名(未選択の場合は「なし」返却) var subName: String { return sub?.name ?? "なし" } } |
YearView に下記を追加すればグラフ表示は完成です。
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 |
import Charts private let charFormatter = YearChartFormatter() private var chartData: [YearChartFormatter.ChartData] { if categorySelection == Category.unselectedValue { return charFormatter.formatAllItems(items) } else { return charFormatter.formatCategoryFilteredItems(items) } } private func makeChartNameList(chartData: [YearChartFormatter.ChartData]) -> [String] { if categorySelection == Category.unselectedValue { return sortedByCategory(chartData).map { $0.category.name } } else { return sortedBySubCategory(chartData).map { $0.category.sub?.name ?? "なし" } } } private func makeChartColorList(chartData: [YearChartFormatter.ChartData]) -> [Color] { if categorySelection == Category.unselectedValue { return sortedByCategory(chartData).map { Color(uiColor: $0.category.color) } } else { return sortedBySubCategory(chartData).map { Color(uiColor: $0.category.sub?.color ?? .lightGray) } } } private func sortedByCategory(_ chartData: [YearChartFormatter.ChartData]) -> [YearChartFormatter.ChartData] { return chartData.sorted { $0.category.value < $1.category.value } } private func sortedBySubCategory(_ chartData: [YearChartFormatter.ChartData]) -> [YearChartFormatter.ChartData] { return chartData.sorted { let value1 = $0.category.sub?.value ?? Int.max let value2 = $1.category.sub?.value ?? Int.max return value1 < value2 } } // 合計金額Textとデータ追加ボタンの間に追加 let data = chartData // 何度も整形処理がはしらないよう代入 Chart(data, id: \.id) { categoryData in ForEach(categoryData.monthlyTotalPrices, id: \.id) { monthlyData in BarMark( x: .value("月", monthlyData.date, unit: .month), y: .value("金額", monthlyData.price) ) .foregroundStyle(by: .value( "項目", categorySelection == Category.unselectedValue ? categoryData.category.name : categoryData.category.subName )) } } .chartXAxis { AxisMarks(values: .stride(by: .month, count: 1)) } .chartForegroundStyleScale( domain: makeChartNameList(chartData: data), range: makeChartColorList(chartData: data) ) |
余白など調整
最後に余白などを調整し YearView の body はこんな感じです。
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 |
VStack { HStack { Picker("年", selection: $year) { ForEach(years, id: \.self) { Text($0 + "年") } } CategoryFilterPicker(categorySelection: $categorySelection) } Spacer() if items.isEmpty { Text("データがありません") } else { Text("\(totalPrice)円") let data = chartData // 何度も整形処理がはしらないよう代入 Chart(data, id: \.id) { categoryData in ForEach(categoryData.monthlyTotalPrices, id: \.id) { monthlyData in BarMark( x: .value("月", monthlyData.date, unit: .month), y: .value("金額", monthlyData.price) ) .foregroundStyle(by: .value( "項目", categorySelection == Category.unselectedValue ? categoryData.category.name : categoryData.category.subName )) } } .chartXAxis { AxisMarks(values: .stride(by: .month, count: 1)) } .chartForegroundStyleScale( domain: makeChartNameList(chartData: data), range: makeChartColorList(chartData: data) ) } Spacer(minLength: 40) Button("データ取得") { Task { do { let items = try await remoteFetch() context.removeItems(year: year) context.addItems(items) localFetch() } catch let error { print(error) } } } } .padding(16) |
おわりに
これで年画面は完成です。Charts があるのでグラフ表示も楽々できるようになりました。SwiftUI はいいですね。
明日は月画面を作成予定です。
コメント