はじめに
こちらは個人開発アプリができるまで by am10 Advent Calendar 2024の 9 日目の記事です。
9 日目は登録画面を作成します。
完成形はこんな感じです。
キャンセルボタン
まずはキャンセルボタンを実装します。
Register.swift ファイルを作成し下記のように実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
import SwiftUI struct RegisterView: View { @Environment(\.dismiss) private var dismiss var body: some View { VStack { HStack { Spacer() Button("キャンセル") { dismiss() } } } } } |
ContentView の登録ボタンの処理を下記のように修正します。
1 2 3 4 5 6 |
FloatingButton() { isPresented = true }.fullScreenCover(isPresented: $isPresented) { // 個々修正 RegisterView() } |
これでキャンセルボタンは完成です。iOS 15 からは dismiss で楽に画面を閉じることができます。
追加データ一覧
次に追加データ一覧を実装します。
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 items: [Item] = [] private let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyy/M/d(E)" return formatter }() List() { ForEach(items, id: \.id) { item in VStack(alignment: .leading) { HStack { Text(dateFormatter.string(from: item.date)) Text("\(item.category.name)(\(item.category.subName))") .padding(4) .foregroundStyle(Color.white) .background(Color(uiColor: item.category.color)) } HStack { Text(item.name) Text("\(item.price)円") } } } .onDelete { indexSet in items.remove(atOffsets: indexSet) } } |
購入日入力
購入日入力は下記を追加するだけです。
1 2 3 |
VStack { DatePicker("購入日", selection: $item.date, displayedComponents: [.date]) } |
品名入力
品名入力は下記を追加するだけです。
1 2 3 |
TextField("品名", text: $item.name) .textFieldStyle(.roundedBorder) .submitLabel(.done) |
金額入力
金額入力は下記を追加するだけです。
1 2 3 4 |
TextField("金額", value: $item.price, format: .number) .keyboardType(.decimalPad) .textFieldStyle(.roundedBorder) .submitLabel(.done) |
項目・サブ項目ピッカー
サブ項目ピッカーは項目選択時にリストを指定の項目の内容に変更する必要があるので項目とサブ項目ピッカーは 1 つの View として作成します。
CategoryPicker.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 |
import SwiftUI struct CategoryPicker: View { /// 選択項目 @Binding var categorySelection: Int /// 選択サブ項目(未選択の場合はCategory.unselectedValue(-1)) @Binding var categorySubSelection: Int private let categoryList = Category.categoryValues private let categorySubList = [ [Category.unselectedValue] + FoodSub.allCases.map { $0.rawValue }, [Category.unselectedValue] + EntertainmentSub.allCases.map { $0.rawValue } ] var body: some View { VStack { HStack { Picker("項目", selection: $categorySelection) { ForEach(categoryList, id:\.self) { value in let category = Category(categoryValue: value, categorySubValue: nil) Text(category.name) .tag(value) } } Picker("サブ項目", selection: $categorySubSelection) { ForEach(categorySubList[categorySelection], id:\.self) { value in let category = Category(categoryValue: categorySelection, categorySubValue: value) Text(category.subName) .tag(value) } } }.onChange(of: categorySelection, { _, _ in categorySubSelection = Category.unselectedValue }) } } } |
RegisterView に下記を追加すれば完成です。
1 2 3 4 5 6 7 8 9 10 |
@State private var item = Item( id: UUID().uuidString, date: Date(), name: "", price: 0, categoryValue: Category.foodValue, categorySubValue: Category.unselectedValue // ここnilから修正 ) CategoryPicker(categorySelection: $item.categoryValue, categorySubSelection: .init( get: { item.categorySubValue! }, set: { item.categorySubValue = $0 } )) |
サブ項目(categorySubValue)の未選択状態を nil として扱うと Picker 操作がしにくいので未選択は Category.unselectedValue として扱っています。
追加・更新ボタン
次に追加・更新ボタンを作成します。
追加
まずは下記のように追加処理を実装します。
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 |
Button("追加") { items.append(makeItem()) clearItem() } extension RegisterView { /// 入力項目から家計簿データを作成する /// - Returns: 家計簿データ private func makeItem() -> Item { return .init(id: item.id, date: item.date, name: item.name, price: item.price, categoryValue: item.categoryValue, categorySubValue: item.categorySubValue == Category.unselectedValue ? nil : item.categorySubValue) } /// 入力項目クリア /// /// 購入日と項目・サブ項目はクリアしない private func clearItem() { item.id = UUID().uuidString item.name = "" item.price = 0 } } |
追加したあとに別のデータ追加をする際は購入日と項目・サブ項目が同じ可能性があるのでクリア処理では品名と金額のみクリアしています。
更新
次に更新処理を実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
// 追加 @State private var itemIDSelection: String? // selection追加 List(selection: $itemIDSelection) { ForEach(items, id: \.id) { item in // ここは同じ } .onDelete { indexSet in items.remove(atOffsets: indexSet) } // ここ追加 .onChange(of: itemIDSelection) { _, newValue in if let newValue = newValue, let item = items.first(where: { $0.id == newValue }) { self.item = item self.item.categorySubValue = item.categorySubValue ?? Category.unselectedValue } } } |
あとは下記のように一覧選択時に追加ボタンを更新ボタンに変更すれば更新処理は完成です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
if itemIDSelection == nil { Button("追加") { items.append(makeItem()) clearItem() } } else { Button("更新") { if let index = items.firstIndex(where: { $0.id == itemIDSelection }) { items[index] = makeItem() } itemIDSelection = nil clearItem() } } |
レシート読み取りボタン
今回は下記を実装するだけです。読み取り処理はまた後で実装します。
1 2 3 4 5 6 7 8 |
Button { // ここにレシート読み取り処理 } label: { HStack { Image(systemName: "text.viewfinder") Text("レシートを読み取る") } } |
交通系 IC カード読み取りボタン
今回は下記を実装するだけです。読み取り処理はまた後で実装します。
1 2 3 4 5 6 7 8 |
Button { // ここに交通系ICカード読み取り処理 } label: { HStack { Image(systemName: "creditcard.viewfinder") Text("交通系ICカードを読み取る") } } |
登録ボタン
登録ボタンは少し目立たせたいので背景塗りつぶしの角丸ボタンにします。
RoundedFillButtonStyle.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 |
import SwiftUI /// 角丸の塗りつぶしボタン struct RoundedFillButtonStyle: ButtonStyle { /// ボタンの背景色 var color: Color = .orange /// ボタンの文字色 var textColor: Color = .white /// 非活性時のボタンの背景色 var disabledColor: Color = .init(uiColor: .lightGray) /// 角の丸み var cornerRadius: CGFloat = 8.0 @Environment(\.isEnabled) private var isEnabled func makeBody(configuration: Configuration) -> some View { configuration.label .padding() .fontWeight(.bold) .foregroundColor(textColor) .background(isEnabled ? color : disabledColor) .opacity(configuration.isPressed ? 0.5 : 1.0) .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) } } |
RegisterView に下記を追加すれば横幅いっぱいのオレンジ色の登録ボタンを表示できます。
1 2 3 4 5 6 7 |
Button { // ここに登録処理 } label: { Text("登録") .frame(maxWidth: .infinity) } .buttonStyle(RoundedFillButtonStyle()) |
登録処理
下記を追加してやれば登録処理は完成です。
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 @Environment(\.modelContext) private var context extension RegisterView { private func register() async throws { let responseItems = try await apiClient.register(items: items.map { $0.convert() }) context.addItems(responseItems) } } // 登録処理 Task { do { try await register() dismiss() } catch let error { print(error) } } |
サーバーに登録したあとローカル DB への保存もしています。登録処理が終わったあとはデータ表示画面に戻りたいはずなので画面を閉じるようにしています。
入力処理の改善
このままだと iPhone では品名・金額入力時のキーボードを閉じる手段がないのでキーボードを閉じられるようにします。
RegisterView に下記のように Field を追加し TextField の処理を修正します。
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 |
enum Field: Hashable { /// 品名 case name /// 金額 case price } @FocusState private var focusedField: Field? TextField("品名", text: $item.name) .textFieldStyle(.roundedBorder) .submitLabel(.done) // ここ追加 .focused($focusedField, equals: .name) .onSubmit { focusedField = .price } TextField("金額", value: $item.price, format: .number) .keyboardType(.decimalPad) .textFieldStyle(.roundedBorder) .submitLabel(.done) // ここ追加 .focused($focusedField, equals: .price) .onSubmit { focusedField = nil } // 登録ボタン Button { focusedField = nil // 登録処理 } |
これで品名入力時に Enter 押下で金額テキストフィールドにフォーカスし、金額入力時に Enter 押下でキーボードを閉じるようになりました。
ついでに登録ボタン押下時にもキーボードを閉じるようにしました。
次にキーボードに閉じるボタンを追加します。
品名テキストフィールドなどの外側の VStack に下記のように toolbar を追加します。
1 2 3 4 5 6 7 8 9 10 11 |
VStack { // データ入力 } .toolbar { ToolbarItemGroup(placement: .keyboard) { Spacer() Button("閉じる") { focusedField = nil } } } |
これでキーボードを閉じられるようになりました。
こんな感じです。
iOS 15 からいろいろ追加されたのでキーボードの操作が楽になりました。
余白など調整
最後に余白などを調整し RegisterView の 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 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 128 |
VStack { HStack { Spacer() Button("キャンセル") { dismiss() } } List(selection: $itemIDSelection) { ForEach(items, id: \.id) { item in VStack(alignment: .leading) { HStack { Text(dateFormatter.string(from: item.date)) Text("\(item.category.name)(\(item.category.subName))") .padding(4) .foregroundStyle(Color.white) .background(Color(uiColor: item.category.color)) } HStack { Text(item.name) Text("\(item.price)円") } } } .onDelete { indexSet in items.remove(atOffsets: indexSet) } .onChange(of: itemIDSelection) { _, newValue in if let newValue = newValue, let item = items.first(where: { $0.id == newValue }) { self.item = item self.item.categorySubValue = item.categorySubValue ?? Category.unselectedValue } } } VStack(spacing: 8) { DatePicker("購入日", selection: $item.date, displayedComponents: [.date]) TextField("品名", text: $item.name) .textFieldStyle(.roundedBorder) .submitLabel(.done) .focused($focusedField, equals: .name) .onSubmit { focusedField = .price } TextField("金額", value: $item.price, format: .number) .keyboardType(.decimalPad) .textFieldStyle(.roundedBorder) .submitLabel(.done) .focused($focusedField, equals: .price) .onSubmit { focusedField = nil } CategoryPicker(categorySelection: $item.categoryValue, categorySubSelection: .init( get: { item.categorySubValue! }, set: { item.categorySubValue = $0 } )) } .toolbar { ToolbarItemGroup(placement: .keyboard) { Spacer() Button("閉じる") { focusedField = nil } } } Spacer(minLength: 16) VStack(spacing: 8) { if itemIDSelection == nil { Button("追加") { items.append(makeItem()) clearItem() } } else { Button("更新") { if let index = items.firstIndex(where: { $0.id == itemIDSelection }) { items[index] = makeItem() } itemIDSelection = nil clearItem() } } Button { // ここにレシート読み取り処理 } label: { HStack { Image(systemName: "text.viewfinder") Text("レシートを読み取る") } } Button { // ここに交通系ICカード読み取り処理 } label: { HStack { Image(systemName: "creditcard.viewfinder") Text("交通系ICカードを読み取る") } } } Spacer(minLength: 80) Button { focusedField = nil Task { do { try await register() dismiss() } catch let error { print(error) } } } label: { Text("登録") .frame(maxWidth: .infinity) } .buttonStyle(RoundedFillButtonStyle()) } .padding(16) |
おわりに
これで登録画面も完成です。家計簿アプリとして最低限の機能はあるんじゃないでしょうか。
明日は交通系 IC カードの読み取り処理を作成予定です。
コメント