はじめに
こちらは個人開発アプリができるまで by am10 Advent Calendar 2024の 8 日目の記事です。
8 日目は月画面を作成します。
完成形はこんな感じです。
グラフ | 一覧 |
---|---|
タブ切替
まずはタブを作成します。MonthView.swift ファイルを作成し下記のように year を追加します。
1 2 |
@Binding var year: String @Binding var month: String |
ContentView.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 |
struct ContentView: View { @State var year: String // これ追加 @State var month: String @State private var isPresented = false var body: some View { ZStack { TabView{ YearView(year: $year) .tabItem { Image(systemName: "y.circle.fill") Text("年") } // ここ修正 MonthView(year: $year, month: $month) .tabItem { Image(systemName: "m.circle.fill") Text("月") } } FloatingButton() { isPresented = true }.fullScreenCover(isPresented: $isPresented) { Text("ここに登録画面") } } } } |
ExpenseLogApp の ContentView の生成処理を下記のように修正します。
1 2 3 4 5 |
ContentView( year: String(Calendar(identifier: .gregorian).component(.year, from: Date())), // これ追加 month: String(Calendar(identifier: .gregorian).component(.month, from: Date())) ) |
これでタブ切替は完成です。
年選択
次に年を選択するためのピッカーを作成します。これは年画面と同じです。
MonthView を下記のように修正します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
struct MonthView: View { @Binding var year: String private var years: [String] { let currentYear = Calendar(identifier: .gregorian).component(.year, from: Date()) return (currentYear - 9...currentYear).map { String($0) } } var body: some View { VStack { HStack { Picker("年", selection: $year) { ForEach(years, id: \.self) { Text($0 + "年") } } } } } } |
月選択
次に月を選択するためのピッカーを作成します。
年選択と同じように MonthView に下記を追加するだけです。
1 2 3 4 5 6 7 8 9 |
private var months: [String] { return (1...12).map { String($0) } } Picker("月", selection: $month) { ForEach(months, id: \.self) { Text($0 + "月") } } |
項目選択
次に項目選択をするピッカーを作成します。これも年画面と同じです。
MonthView に下記を追加するだけです。
1 2 3 |
@State private var categorySelection: Int = Category.unselectedValue CategoryFilterPicker(categorySelection: $categorySelection) |
登録ボタン
登録ボタンは年画面作成時にすでに ContentView に追加済みなのでやることはありません。
データ取得
次にデータ取得処理です。年画面作成時とほぼ同じです。
サーバーからデータ取得
サーバーから取得する処理は下記のようにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
@Environment(\.apiClient) private var apiClient Button("データ取得") { Task { do { let items = try await remoteFetch() print(items.count) } catch let error { print(error) } } } extension MonthView { /// サーバーから選択年月の家計簿データを取得する func remoteFetch() async throws -> [ResponseItem] { return try await apiClient.fetch(year: year, month: month) } } |
ローカルにデータを保存
ローカルにデータを保存するには 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 |
// 年月と項目を指定して家計簿データを取得する /// - Parameters: /// - year: 指定の年(yyyy形式) /// - month: 指定の月(M形式、1~12) /// - category: 指定の項目(nilの場合は指定の年月のデータをすべて取得) /// - Returns: 指定の年月と項目の家計簿データ func fetchItems(year: String, month: String, category: Category?) -> [Item] { let calendar = Calendar(identifier: .gregorian) let components = DateComponents(calendar: calendar, year: Int(year)!, month: Int(month)!, day: 1) let startDate = components.date! let endDate = calendar.date(byAdding: DateComponents(month: 1, day: -1), to: startDate)! 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 ?? [] } /// 指定の年月の家計簿データを削除する /// - Parameters: /// - year: 指定の年(yyyy形式) /// - month: 指定の月(M形式、1~12) func removeItems(year: String, month: String) { let items = fetchItems(year: year, month: month, category: nil) items.forEach { delete($0) } } |
MonthView を下記のように修正します。
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, month: month, category: category) } |
年画面と同じように下記を追加しすればそれぞれのピッカー選択時に指定のデータを取得できるようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// ルートのVStackに追加 .onChange(of: year) { _, _ in localFetch() } .onChange(of: month) { _, _ in localFetch() } .onChange(of: categorySelection) { _, _ in localFetch() } .onAppear() { localFetch() } |
合計金額表示
年画面同様に下記を追加して合計金額を表示します。
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)円") |
グラフ・一覧切替
次にグラフ表示と一覧表示を切り替えられるようにします。
MonthView に下記を追加してセグメントコントロールで切り替えられるようにします。
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 |
/// 表示モード enum DisplayMode: Int, Identifiable, CaseIterable { /// 円グラフ case pieChart /// 一覧 case list var id: Int { return rawValue } var name: String { switch self { case .pieChart: return "円グラフ" case .list: return "一覧" } } } @State private var displayMode = DisplayMode.pieChart Picker("表示", selection: $displayMode) { ForEach(DisplayMode.allCases) { Text($0.name).tag($0) } } .pickerStyle(.segmented) Text("\(totalPrice)円") switch displayMode { case .pieChart: Text("ここに円グラフ") case .list: Text("ここに一覧") } |
円グラフ
まずは円グラフ表示を作成します。
円グラフを表示するには項目(もしくはサブ項目)ごとにデータをわける必要があります。
年画面と同じように MonthChartFormatter.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 |
import Foundation struct MonthChartFormatter { /// 各項目(またはサブ項目)の家計簿データの合計金額 struct ChartData { /// ID let id = UUID().uuidString /// 項目 let category: Category /// 任意の月と項目(またはサブ項目)の合計金額 let price: Int } /// 任意の年月のすべての家計簿データを円グラフ表示用に整形する /// - Parameter items: 任意の年月の家計簿データ /// - Returns: 円グラフ表示用に整形した家計簿データ func formatAllItems(_ items: [Item]) -> [ChartData] { if items.isEmpty { return [] } // 項目ごとにグルーピング let categoryGrouping = Dictionary( grouping: items, by: { $0.categoryValue } ) return categoryGrouping.keys.map { categoryValue in ChartData( category: .init(categoryValue: categoryValue, categorySubValue: nil), price: categoryGrouping[categoryValue]!.reduce(0) { partialResult, item in partialResult + item.price }) } } /// 任意の年月の任意の項目の家計簿データを円グラフ表示用に整形する /// - Parameter items: 任意の年月の任意の項目の家計簿データ /// - Returns: 円グラフ表示用に整形した家計簿データ func formatCategoryFilteredItems(_ items: [Item]) -> [ChartData] { if items.isEmpty { return [] } // サブ項目ごとにグルーピング let categorySubGrouping = Dictionary( grouping: items, by: { $0.categorySubValue } ) let categoryValue = items.first!.categoryValue return categorySubGrouping.keys.map { categorySubValue in ChartData( category: .init(categoryValue: categoryValue, categorySubValue: categorySubValue), price: categorySubGrouping[categorySubValue]!.reduce(0) { partialResult, item in partialResult + item.price }) } } } |
グラフ表示は複雑になりそうなので MonthView とはクラスをわけます。
MonthChartView.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 |
import Charts import SwiftUI struct MonthChartView: View { @Binding var items: [Item] @Binding var categorySelection: Int private let charFormatter = MonthChartFormatter() var body: some View { let data = chartData // 何度も整形処理がはしらないよう代入 Chart(data, id: \.id) { categoryData in SectorMark( angle: .value("金額", categoryData.price) ) .foregroundStyle(by: .value( "項目", categorySelection == Category.unselectedValue ? categoryData.category.name : categoryData.category.subName )) } .chartForegroundStyleScale( domain: makeChartNameList(chartData: data), range: makeChartColorList(chartData: data) ) } } extension MonthChartView { private var chartData: [MonthChartFormatter.ChartData] { if categorySelection == Category.unselectedValue { return charFormatter.formatAllItems(items) } else { return charFormatter.formatCategoryFilteredItems(items) } } private func makeChartNameList(chartData: [MonthChartFormatter.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: [MonthChartFormatter.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: [MonthChartFormatter.ChartData]) -> [MonthChartFormatter.ChartData] { return chartData.sorted { $0.category.value < $1.category.value } } private func sortedBySubCategory(_ chartData: [MonthChartFormatter.ChartData]) -> [MonthChartFormatter.ChartData] { return chartData.sorted { let value1 = $0.category.sub?.value ?? Int.max let value2 = $1.category.sub?.value ?? Int.max return value1 < value2 } } } |
MontView を下記のように修正したら円グラフ表示は完成です。
1 2 3 4 5 6 7 |
switch displayMode { case .pieChart: // 個々修正 MonthChartView(items: $items, categorySelection: $categorySelection) case .list: Text("ここに一覧") } |
一覧
次に一覧表示です。Category に下記を追加します。
1 2 3 4 5 6 7 |
extension Item { /// 項目とサブ項目 var category: Category { return .init(categoryValue: categoryValue, categorySubValue: categorySubValue) } } |
円グラフ表示を別クラスにしたので一覧表示も別クラスで行います。
MonthListView.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 |
import SwiftUI struct MonthListView: View { @Binding var items: [Item] @Binding var categorySelection: Int private let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "d(E)" return formatter }() var body: some View { List { ForEach(items, id: \.id) { item in VStack(alignment: .leading, spacing: 4) { HStack { Text(dateFormatter.string(from: item.date)) Text(item.name) } HStack { if categorySelection == Category.unselectedValue { Text("\(item.category.name)(\(item.category.subName))") .font(.system(size: 14)) .padding(4) .foregroundStyle(Color.white) .background(Color(uiColor: item.category.color)) } else { Text("\(item.category.subName)") .font(.system(size: 14)) .padding(4) .foregroundStyle(Color.white) .background(Color(uiColor: item.category.sub?.color ?? .lightGray)) } Text("\(item.price)円") } } } } } } |
これで一覧表示もできました。
ソート
一覧表示はできたのですが一覧をみたらソート機能がほしいなと思ったのでソート機能を追加します。
MonthListView を下記のように修正します。
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 |
struct MonthListView: View { /// ソートの種類 enum SortType: Int, Identifiable, CaseIterable { /// 購入日 case date /// 金額 case price var id: Int { return rawValue } var name: String { switch self { case .date: return "購入日" case .price: return "金額" } } } @Binding var items: [Item] @Binding var categorySelection: Int @State private var sortType = SortType.date @State private var isAscending = true private let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "d(E)" return formatter }() var body: some View { VStack { HStack { Picker("ソート", selection: $sortType) { ForEach(SortType.allCases) { Text("\($0.name)順").tag($0) } } Button(action: { isAscending.toggle() }, label: { Image(systemName: isAscending ? "arrow.up.right" : "arrow.down.right") }) } // Listの処理は同じなので省略 } .onChange(of: items) { _, _ in sort() } .onChange(of: sortType) { _, _ in sort() } .onChange(of: isAscending) { _, _ in sort() } .onAppear() { sort() } } } extension MonthListView { private func sort() { switch sortType { case .date: items.sort { if isAscending { $0.date < $1.date } else { $0.date > $1.date } } case .price: items.sort { if isAscending { $0.price < $1.price } else { $0.price > $1.price } } } } } |
これで一覧表示で購入日と金額の昇順・降順でソートできるようになりました。
余白など調整
最後に余白などを調整し MonthView の 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 + "年") } } Picker("月", selection: $month) { ForEach(months, id: \.self) { Text($0 + "月") } } CategoryFilterPicker(categorySelection: $categorySelection) } Picker("表示", selection: $displayMode) { ForEach(DisplayMode.allCases) { Text($0.name).tag($0) } } .pickerStyle(.segmented) Spacer() if items.isEmpty { Text("データなし") } else { Text("\(totalPrice)円") switch displayMode { case .pieChart: MonthChartView(items: $items, categorySelection: $categorySelection) case .list: MonthListView(items: $items, categorySelection: $categorySelection) } } Spacer(minLength: 40) Button("データ取得") { Task { do { let items = try await remoteFetch() context.removeItems(year: year, month: month) context.addItems(items) localFetch() } catch let error { print(error) } } } } .padding(16) |
おわりに
これで月画面も完成です。SwiftUI なら一覧表示もサクッとできちゃいます。
明日は登録画面を作成予定です。
コメント