Swift UIで割り勘アプリを作ってみた！改良ver

SwiftUIを学んでいく過程でポートフォリオがてら作成した「**割り勘アプリ**」の作成手順と方法をまとめていきます。

初心者の方でも同じものが作れるように解説できたらなと考えています。

また至らぬ点や拙いところもあると思いますが優しく教えていただけると嬉しいです。

今回↓前回の記事で作成したアプリを改良していきます。

[SwiftUIで割り勘アプリの作り方を徹底解説！](https://qiita.com/amefure/items/9e158e538b695667d27f)

# 今回作っていくアプリ

:::note
割り勘アプリ 改良ver
:::

![happy bithday.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2264957/0285677b-b737-c606-9e1b-602dec1e7504.png)


● 追加する機能
+ メモも入力可能にする
+ 入力された情報をリスト表示
+ リストから情報の削除を可能に
+ 割り勘できる人数にカスタムを追加
+ アプリを落としても情報を保持

***

● 環境
+ Swift
+ SwiftUI
+ Xcode
+ Macbook M1チップ

***

● 作成(編集)するファイル
+ ContentView.swift：入力ビュー
+ CalcBillView.swift：計算ビュー※名前変更しました
+ FileController.swift：ファイル操作クラス
+ CashData.swift：構造体とクラスを定義
+ ListCashView：1行を元にリスト表示するビュー
+ RowCashView.swift：リスト表示の1行を定義


## アプリ制作の手順
1. [前回の記事](https://qiita.com/amefure/items/9e158e538b695667d27f)まで作成
1. CashData.swiftファイルを作成しJSONファイルと紐付ける構造体を作成
1. FileController.swiftのクラスをJSON用に修正
1. RowCashView.swiftファイルを作成しユーザーの情報をリスト表示するための1行を作成
1. ListCashView.swiftファイルを作成しRowCashViewを基にリスト表示するビューの作成
1. CalcBillView.swiftに人数をカスタムで入力できるように修正
1. ContentView.swiftの調整
1. アプリアイコンのセット

## JSONと紐付く構造体の定義
前回は「**bill.txt**」に**金額だけをそのまま記述**していましたが、今回はユーザーが入力できる情報を「金額」と「メモ」に増やしたいのでファイルのデータの書き込みを下記のようなJSON形式にします。ついでにファイル名も「**cashData.json**」に変更しました。

```javascript:cashData.json
[
  {"id":"3F4D8393-8D86-4C81-AFB9-1854A5E38BDC","memo":"高速代金","cash":2800,"time":"22:38"},
  {"id":"BF3195B0-B726-481D-ABD1-F8D87174AF28","memo":"宿代","cash":25000,"time":"22:38"},
  {"id":"A599D155-CFE7-4A90-A2C3-CAA1EB1D00C4","memo":"駐車場代","cash":900,"time":"6:06"}
]
```

そのためには**入力された情報をJSON形式に変換しないといけません**。Swiftでは**構造体を簡単にJSON形式の文字列に変換**することができます。

なのでまずは必要となる構造体を定義していきます。新規ファイル「**CashData.swift**」を作成し、中に以下のように記述します。

```swift:CashData.swift
struct CashData: Identifiable,Codable,Equatable {
    // キャッシュ情報を統括して管理する構造体

    var id = UUID()             // 一意の値
    var cash:Int                // 金額情報
    var memo:String = ""        // MEMO
    var time:String = { // 初期値に現在の日付
        
        let df = DateFormatter()
        df.calendar = Calendar(identifier: .gregorian)
        df.locale = Locale(identifier: "ja_JP")
        df.timeZone = TimeZone(identifier: "Asia/Tokyo")
        df.dateStyle = .none
        df.timeStyle = .short
        return df.string(from: Date())
    }()
}

```
構造体には「金額(cash)」と「メモ(memo)」用のプロパティと一意に識別するようの「id」と「時刻」を定義しておきます。idは重複しないように``UUID()``を指定します。また構造体は
``List``を使って表示できるように``Identifiable``プロトコルに、JSONデータに変換できるように``Codable``プロトコルに準拠させておきます。``Equatable``プロトコルに関しては後述します。


[【Swift UI】Identifiableとは？プロトコルとUUIDの使い方](https://tech.amefure.com/swift-identifiable#header)

日付の整形方法についてはこちらの記事をご覧ください。

[【Swift】DateFormatterの使い方！書式や日付形式の調整方法](https://tech.amefure.com/swift-date-formatter)

:::note
ポイント
+  Swiftでは構造体をJSON形式に変換可能
+  一意にしたいプロパティにはUUID()
+  Listで表示したい構造体にはIdentifiableプロトコルの準拠が必須
+  JSON形式へ変換する構造体にはCodableプロトコルの準拠が必須
:::


## JSONファイルとして保存する
構造体の定義を終えたら、JSON形式に変換してファイルに保存していく処理を記述します。前回のFileController.swiftからの差分は以下の通りです。

```diff_swift:FileController.swift
- private let fileName:String = "bill.txt" // 削除
+ private let jsonName:String = "cashData.json"
func docURL() -> URL? {} // 編集して流用
- func writingFile(_ text:String) {} // 削除
- func readingFile()->Int{} // 削除
func clearFile() {} // 編集して流用
- func changeNum(_ text:String) -> Int{} // 削除(クラス名から逸出した処理だったため別場所に後で再定義)

+ func hasJson () -> Bool{} // ファイルが存在するかどうか
+ func saveJson(_ cash:CashData) {} // ユーザーが新規で追加した情報を追記(書き込み処理)
+ func updateJson(_ allCash:[CashData]) {} // ライン削除され変化した構造体を再度ファイルにアップデート(書き込み処理)
+ func loadJson() -> [CashData] {} // 現在のJSONデータを構造体に変換
```

:::note
ポイント
+  JSONEncoder/JSONDecoderの扱い
+  JSONデータを扱う際の文字コード:UTF-8のData型への変換
+  構造体との紐付け方法
+  配列に格納された構造体([構造体])形式への追加処理
:::

JSONファイルを操作する処理に関してはこちらをご覧ください。

[【Swift】JSONデータをエンコードする方法！JSONEncoderクラスの使い方](https://tech.amefure.com/swift-json-encoder)


[【Swift】JSONデータをデコードする方法！JSONDecoderクラスの使い方](https://tech.amefure.com/swift-json-decoder)

ここではコードのみ記載しておきます。中身は以下の通りです。

```swift:FileController.swift
import Foundation

class FileController {
        
    // Documents内で操作するJSONファイル名
    private let jsonName:String = "cashData.json"
    
    // 保存ファイルへのURLを作成 file::Documents/fileName
    func docURL() -> URL? {
        let fileManager = FileManager.default
        do {
            // Docmentsフォルダ
            let docsUrl = try fileManager.url(
                for: .documentDirectory,
                in: .userDomainMask,
                appropriateFor: nil,
                create: false)
            // URLを構築
            let url = docsUrl.appendingPathComponent(jsonName)
            
            return url
        } catch {
            return nil
        }
    }


    // ファイル削除処理
    func clearFile() {
        guard let url = docURL() else {
            return
        }
        do {
            try FileManager.default.removeItem(at: url)
        } catch {
        }
    }
    
    
    // 操作するJsonファイルがあるかどうか
    func hasJson () -> Bool{
        
        let str =  NSHomeDirectory() + "/Documents/" + jsonName
        if FileManager.default.fileExists(atPath: str) {
            return true
        }else{
            return false
        }
    }
    
    // 登録する一件のキャッシュデータを受け取る
    // 現在のキャッシュALL情報を取得し構造体に変換してから追加
    // 再度JSONに直し書き込み
    func saveJson(_ cash:CashData) {
        guard let url = docURL() else {
            
            return
        }
        
        var cashArray:[CashData]
        
        cashArray = loadJson() // [] or [CashData]
        cashArray.append(contentsOf: [cash]) // いずれにせよ追加処理
    
        let encoder = JSONEncoder()
        let data = try! encoder.encode(cashArray)
        let jsonData = String(data:data, encoding: .utf8)!
       
        do {
            // ファイルパスへの保存
            let path = url.path
            try jsonData.write(toFile: path, atomically: true, encoding: .utf8)
        } catch let error as NSError {
            print(error)
        }
    }
    
    
    // ListCashViewからremoveされたデータを保存する
    func updateJson(_ allCash:[CashData]) {
        guard let url = docURL() else {
            return
        }
        
        let encoder = JSONEncoder()
        let data = try! encoder.encode(allCash)
        let jsonData = String(data:data, encoding: .utf8)!
        
        do {
            // ファイルパスへの保存
            let path = url.path
            try jsonData.write(toFile: path, atomically: true, encoding: .utf8)
        } catch let error as NSError {
            print(error)
        }
    }
    
    // JSONデータを読み込んで[構造体]にする
    func loadJson() -> [CashData] {
        
        guard let url = docURL() else {
            return []
        }
        
        if hasJson() {
            // JSONファイルが存在する場合
            let jsonData = try! String(contentsOf: url).data(using: .utf8)!
            let cashArray = try! JSONDecoder().decode([CashData].self, from: jsonData)
            return cashArray
            
        }else{
            // JSONファイルが存在しない場合
            return []
            
        }
    }
}
```

## リスト表示させる1行を定義する

![スクリーンショット 2022-07-14 22.48.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2264957/c5773492-0a5c-a99f-7e76-95c523787f38.png)

新規に「**RowCashView.swift**」を作成しここにリスト表示させる1行単位のデザインを記述していきます。今回は上記のようにします。

:::note
ポイント
+  offset(x:　y:)で表示位置を微調整
+  Rectangleで縦線を表現
:::

```swift:RowCashView.swift
struct RowCashView: View {
    
    // プロパティ------------------------------------------------------
    var item:CashData
    
    var body: some View {
        
        HStack {
            VStack {
                // 位置調整
                Text("memo").font(.caption).foregroundColor(.gray).offset(x: 52, y: -16)
                Text("\(item.time)").font(.caption).foregroundColor(.gray).offset(x: -10, y: 0)
            }
        
            // 縦の線を表示
            Rectangle()
            .foregroundColor(.gray)
            .frame(width: 0.2 ,height: 30)
            
            // メモ
            Text("\(item.memo)").lineLimit(1)
            
            Spacer()
            
            // 金額
            Text("¥\(item.cash)").lineLimit(1)
            
        }.padding([.top,.trailing])
    }
}

struct RowCashView_Previews: PreviewProvider {
    static var previews: some View {
        RowCashView(item: CashData(cash:25000,memo:"宿代")).previewLayout(.sizeThatFits)
    }
}
```

## リスト表示用のビューを作成
新規に「**ListCashView.swift**」を作成し先ほどの**1行を元にリスト表示させるビュー**を作成していきます。

:::note
ポイント
+  @ObservedObjectを使ったクラスの監視
+  親のメソッドを受け取るための空の関数の用意
+  スワイプアクションで削除可能に
:::

[【Swift UI】@ObservedObjectの意味と使い方！クラスとプロトコルとの関係](https://tech.amefure.com/swift-observableobject)

[【SwiftUI】親ビューのメソッドを子ビューで呼び出す方法](https://tech.amefure.com/swift-parent-method)

![happy bithday.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2264957/b9e72dcb-056a-d268-7a55-d8ec9ae8a7a4.png)

```swift:ListCashView.swift
struct ListCashView: View {

    // インスタンス----------------------------------------------------
    // ファイルコントローラークラスをインスタンス化
    let fileController = FileController()
    // 全キャッシュ情報をデータとして持つクラスをインスタンス化
    @ObservedObject var allCashData = AllCashData()
    
    // 親のメソッドを受け取る--------------------------------------------
    var parentRefreshFunction: () -> Void
    
    var body: some View {
        VStack {
            
            // .reversed()：逆順
            List (allCashData.allData.reversed()) { item in
                
                RowCashView(item: item)
                    .swipeActions(edge: .trailing,allowsFullSwipe: false){
                        Button(role:.destructive,action: {
                            
                            // リストの削除処理にゆっくりのアニメーション
                            withAnimation(.linear(duration: 0.3)){
                                
                                allCashData.removeCash(item)   // 選択されたitemを削除
                                fileController.updateJson(allCashData.allData) // JSONファイルを更新
                                allCashData.setAllData() // JSONファイルをプロパティにセット
                                self.parentRefreshFunction() // 親Viewのクラスをリフレッシュ
                                
                            }
                            
                        }, label: {
                            Image(systemName: "trash")
                        })

                        
                    }
            }
        }
    }
}

struct ListCashView_Previews: PreviewProvider {
    static var previews: some View {
        ListCashView(parentRefreshFunction: {})
    }
}
```

## 割り勘できる人数にカスタム値を追加

:::note
ポイント
+  TextFieldを使ってカスタム欄の追加
+  子ビューになるキーボードのフォーカスは親から制御
:::

```swift:ContentView.swift
struct CalcBillView: View {
    
    // View----------------------------------------------------------
    var grids = Array(repeating: GridItem(.fixed(80), spacing: 20), count: 3)
    // プロパティ------------------------------------------------------
    @State var people:Int = 1 // 割り勘にする人数を格納
    @Binding var bill:Int     // 請求金額 親とバインディング
    @State var customStr:String = "" // 割り勘にする人数を格納
    
    // 関数-----------------------------------------------------------
    func setPeople(_ str:String){
        let num = changeNum(str)
        if num > 0 {
            people = num
        }
        customStr = ""
    }
    
    // 文字列を数値に変換
    func changeNum(_ text:String) -> Int{
        guard let num = Int(text) else{
            return 0
        }
        return num
    }
    
    // 関数-----------------------------------------------------------
    
    var body: some View {

        
        VStack {
            HStack {
                Text("合計：")
                Text("¥\(bill)").font(.custom("AppleSDGothicNeo-SemiBold", size: 50)).foregroundColor(.gray).lineLimit(1)
            }.padding()
            HStack {
                Text("\(people)人で割ると.....").foregroundColor(.gray)
            }.padding()
            HStack {
                Text("1人：")
            
                Text("¥\(bill / people)").font(.custom("AppleSDGothicNeo-SemiBold", size: 50)).foregroundColor(.gray).lineLimit(1)
            }.padding()
            LazyVGrid(columns: grids){
                Group {
                    
                    Button(action: {
                        people = 2
                    }, label: {
                        Image(systemName: "figure.stand").offset(x: 5)
                        Image(systemName: "figure.stand").offset(x: -5)
                    }).padding().frame(width: 80, height: 80)
                    
                    Button(action: {
                        people = 3
                    }, label: {
                        Image(systemName: "figure.stand").offset(x: 10)
                        Image(systemName: "figure.stand")
                        Image(systemName: "figure.stand").offset(x: -10)
                    }).padding().frame(width: 80, height: 80)
                    
                    Button(action: {
                        people = 4
                    }, label: {
                        Image(systemName: "figure.stand").offset(x: 15)
                        Image(systemName: "figure.stand").offset(x: 5)
                        Image(systemName: "figure.stand").offset(x: -5)
                        Image(systemName: "figure.stand").offset(x: -15)
                    }).padding().frame(width: 80, height: 80)
                    
                }.background(Color(red: 0.2, green: 0.5 ,blue: 0.2))
                .cornerRadius(8)
                .foregroundColor(Color.white)
                
                
            }.padding()
            
            VStack {
                Text("割り勘する人数(カスタム)").foregroundColor(.gray)
            TextField("人", text: $customStr)
                .keyboardType(.numberPad)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .multilineTextAlignment(.trailing)
                .frame(width: 200)
                // .focused($isActive) は親側に指定する
            }
            Button(action: {
                setPeople(customStr)
            }, label: {
                Text("確定")
            })
            
            
        }.frame( maxWidth: .infinity, maxHeight: .infinity)
            .ignoresSafeArea()
  
    }
}

struct CalcBillView_Previews: PreviewProvider {
    static var previews: some View {
        CalcBillView(bill: Binding.constant(1000))
            .previewInterfaceOrientation(.portrait)
    }
}
```

## ContentView(親ビュー)からリストの呼び出しや修正
:::note
ポイント
+  ダークモード対応
+  NavigationLinkの有効/無効
+  NavigationLinkからリストビューの呼び出し
+  数字のみのキーボードへの閉じるボタンの実装
:::

[【Swift UI】ダークモード時にテキストの文字色を変更する方法！](https://tech.amefure.com/swift-dark-mode)

[【SwiftUI】NavigationLinkを条件によって画面遷移させる方法](https://tech.amefure.com/swift-navigationlink-switch)

```swift:ContentView.swift
struct ContentView: View {
    
    // View----------------------------------------------------------
    @State var selectedTag = 1      //  タブビュー
    @State var isAlert:Bool = false // アラート
    @State var isLinkEnable:Bool = false // アラート
    @FocusState var isActive:Bool   // キーボードフォーカス
    // ダークモード対応
    @Environment(\.colorScheme) var colorScheme: ColorScheme

    
    // インスタンス----------------------------------------------------
    // ファイルコントローラークラスをインスタンス化
    let fileController = FileController()
    // 全キャッシュ情報をデータとして持つクラスをインスタンス化
    @ObservedObject var allCashData = AllCashData()
    
    // プロパティ------------------------------------------------------
    
    @State var cash:String = ""  // 入力された金額情報
    @State var memo:String = ""  // 入力されたMemo情報
    @State var isCorrect:Bool = true // 入力された金額が数値かどうか
    
    // 関数-----------------------------------------------------------
    // 文字列を数値に変換
    func changeNum(_ text:String) -> Int{
        guard let num = Int(text) else{
            isCorrect = false
            return 0
        }
        isCorrect = true
        return num
    }
    
    
    // 入力フォームをリセット
    func deleteInput(){
        cash = "" // 入力値をクリア
        memo = "" // 入力値をクリア
    }
    
    // 全情報をリフレッシュ 子Viewに渡す
    func refreshData(){
        allCashData.setAllData() // AllDataインスタンスのプロパティをリセット
        allCashData.sumBill()  // 請求金額をリセット
    }
    
    // -------------------------------------------------------------
    
    
    
    
    var body: some View {
        
        TabView(selection: $selectedTag){
            NavigationView{
            // cash蓄積View
            VStack {
                
                
                //  合計請求金額---------------------------------------
                Text("¥\(allCashData.bill)").font(.custom("AppleSDGothicNeo-SemiBold", size: 50)).foregroundColor(.gray).lineLimit(1)
                //  合計請求金額---------------------------------------
                
                //  入力フォーム---------------------------------------
                Group {
                    // 金額
                    TextField("¥",text: $cash)
                        .keyboardType(.numberPad)
                        .foregroundColor(isCorrect ? (colorScheme == .dark ? Color.white : Color.black) : .red)

                    // メモ
                    TextField("memo",text: $memo)
                                                
                }.textFieldStyle(RoundedBorderTextFieldStyle())
                .focused($isActive)
                .multilineTextAlignment(.trailing)
                .frame(width: 200)
                //  入力フォーム---------------------------------------
                
                //  ボタン---------------------------------------
                HStack (spacing: 30){
                    
                        //  リセットボタン
                        Button(action: {
                            isAlert = true // アラートを表示
                        }, label: {
                            Text("リセット").frame(width: 100)
                        }).padding()
                        .background(Color(red: 0.2, green: 0.5 ,blue: 0.2))
                        .cornerRadius(8)
                        .alert(isPresented: $isAlert) {
                            Alert(title: Text("確認"), message: Text("データをリセットしてもよろしいですか？"),
                                primaryButton: .destructive(Text("削除する"),action: {
                                fileController.clearFile() // ファイルをクリア
                                refreshData() // データのリフレッシュ
                                deleteInput() // 入力フォームのリセット
                            }), secondaryButton: .cancel(Text("キャンセル")))
                        }
                        //  リセットボタン
                    
                        //  登録ボタン
                        Button(action: {
                            // TextField文字列を数値に変換
                            let num = changeNum(cash)

                            // 数値じゃない場合and0じゃない場合
                            if num != 0 {
                                // 構造体に倣って構築
                                let cashData = CashData(cash:num,memo:memo)
                                // 構造体を保存
                                fileController.saveJson(cashData)
                                refreshData()  // データのリフレッシュ
                                deleteInput()  // 入力フォームのリセット
                            }

                        }, label: {
                            Text("登録").frame(width: 100)
                        }).padding()
                        .background(Color(red: 0.2, green: 0.5 ,blue: 0.2))
                        .cornerRadius(8)
                        //  登録ボタン
                        
                }.padding()
                    .foregroundColor(.white)
                    
                    
                //  ボタン---------------------------------------
                
                // リスト表示ボタンリンク----------------------------------
                
                Button(action: {
                    if allCashData.allData.isEmpty {
                        isLinkEnable = false
                    }else{
                        isLinkEnable = true
                    }
                    
                }, label: {
                    Spacer()
                    Image(systemName: "list.bullet").padding()
                    .font(.system(size: 20))
                    
                })
                
                NavigationLink(destination: ListCashView(parentRefreshFunction: self.refreshData),isActive: $isLinkEnable, label: {
                    EmptyView()
                })
                
      
                // リスト表示ボタンリンク----------------------------------
                
                
                // 3行くらいデモ表示したい
                ScrollView{
                    
                    LazyVStack {
                        ForEach (allCashData.allData.reversed()) { item in
                            RowCashView(item: item)
                        }.frame(height: 50)
                            .padding([.leading,.trailing])
                    }
                }.frame(height: 150)
                
                
            } // Navigation
                
            }.tabItem{

                    Image(systemName: "pencil.circle")
                    Text("Add")
                
            }.tag(1)
                .frame( maxWidth: .infinity, maxHeight: .infinity)
                .ignoresSafeArea()
            
                
            
            // 割り勘計算View
            CalcBillView(bill: $allCashData.bill).tabItem{
                Image(systemName: "yensign.circle")
                Text("Calc")
            }.tag(2)
                .focused($isActive) // 子ViewのTextFieldのフォーカスはここに指定
        }
        .ignoresSafeArea()
        .accentColor(.orange)
        .toolbar{
            ToolbarItemGroup(placement: .keyboard, content: {
                Spacer()
                Button("閉じる"){
                    isActive = false
                }
            })
        }
        
    }
}
```

## 追加した機能の実装方法まとめ

● 追加した機能
+ メモも入力可能にする　　　　　　　→　蓄積ファイルをJSON形式に
+ 入力された情報をリスト表示　　　　→ 　JSONを構造体への変換とList表示
+ リストから情報の削除を可能に　　　→　スワイプアクションとFile操作の紐付け
+ 割り勘できる人数にカスタムを追加　→　TextFieldの追加
+ アプリを落としても情報を保持　　　→ 　データをファイルに格納

***

## 作ってみた感想
今回は前回からのアプリの改良とはいえ自分的に新しく使う機能やコードが多くなかなか苦戦しました。

ですがSwiftにおけるJSONコードの扱いとファイルをデータベースのように扱う流れはとても勉強になった気がします。

ご覧いただきありがとうございました。