SwiftUIで割り勘アプリの作り方を徹底解説！



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

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

拙い点や至らぬ点も多いと思いますがよろしくお願いいたします。

# 今回作っていくアプリ

:::note
割り勘アプリ
:::

● 機能
+ 金額を入力して蓄積していく
+ 割り勘にする人数を指定
+ トータル金額　÷ 人数　= 一人当たりの金額

***

● 環境
+ SwiftUI

***

● 作成(編集)するページ
+ ContentView(入力ビュー)
+ CalcBill(計算ビュー)
+ FileController(ファイル操作クラス)

***

● 作成していくポイント
+ ユーザーが金額を入力できるようにする
+ 入力された金額を蓄積
+ 蓄積した金額のリセット
+ 割り勘する人数の指定

## アプリ制作の手順
1. 新規プロジェクトの作成
1. ContentViewにユーザー入力フォームを作成
1. FileController.swiftファイルを作成し入力値をファイルへ蓄積する処理を作成
1. ContentViewに入力値を足し算と削除する処理を作成
1. CalcBill.swiftファイルを作成し割り勘の計算処理を作成
1. 作成したViewファイルをタブビューで繋げる
1. 完成

## 新規プロジェクトの立ち上げ

今回はSwiftUIを使ってiOSアプリを作成していきます。必要になってくるのは**XCode**のみです。

[Xcodeのインストール方法と使い方！SwiftとSwiftUIの違い](https://tech.amefure.com/swift-xcode-install)

XCodeを起動して、「**Create a new Xcode project**」をクリックし、アプリ名を入力します。今回は「**Bill**」としました。Bill=請求書の意


最初から「ContentView」は用意されているのでここに追加していきます。

## ユーザー入力画面の作成
「ContentView」には**ユーザーが金額を入力するフォームを実装**します。その前に必要となる変数を宣言しておきます。

```swift:ContentView
struct ContentView: View {
    @State var bill:Int = 0      // 請求金額
    @State var cash:String = ""  // 入力された金額情報  

       var body: some View {

       }
}
```

[【Swift UI】@Stateの意味と使い方とは？mutatingとの違い](https://tech.amefure.com/swift-state)

請求金額を表示する部分はTextを使います。後続のモディファイア``font(.custom("AppleSDGothicNeo-SemiBold", size:50))``などで書体とサイズ、文字色、表示する行数を指定します。

SwiftUIでは**TextField構造体**を使って入力フォームを簡単に実装できます。

```swift:ContentView
var body: some View {
   VStack {
       Text("¥\(bill)").font(.custom("AppleSDGothicNeo-SemiBold", size:50)).foregroundColor(.gray).lineLimit(1)
       TextField("¥",text: $cash)
             .textFieldStyle(RoundedBorderTextFieldStyle()) // 見た目を調整
             .keyboardType(.numberPad) // 数字キーのみにする
             .multilineTextAlignment(.trailing) // 右寄せ
             .frame(width: 200) // サイズを調整
   }
}
```

![スクリーンショット 2022-07-06 20.44.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2264957/ac10f383-5587-642f-cd43-3f98556bfbb5.png)

TextFieldには**入力された値を格納するための変数をバインディング**します。今回は数値のみの入力に制限したいので``keyboardType(.numberPad)``を付与し、**数字キーのみの表示**にします。その弊害としてキーボードを閉じることができなくなってしまうので**閉じるボタンの実装が必要**になってきます。

詳細はこちらの記事に載せているのでここではコードだけ記載しておきます。
[【Swift UI】TextFieldのキーボードを閉じる方法と@FocusStateの使い方](https://tech.amefure.com/swift-close-keyboard)

```diff_swift:ContentView
struct ContentView: View {
    
+    @FocusState var isActive:Bool // キーボードフォーカス
     @State var bill:Int = 0      // 請求金額
     @State var cash:String = ""  // 入力された金額情報  

     var body: some View {
       VStack {
          Text("¥\(bill)").font(.custom("AppleSDGothicNeo-SemiBold",size:50)).foregroundColor(.gray).lineLimit(1)
          TextField("¥",text: $cash)
             .textFieldStyle(RoundedBorderTextFieldStyle()) // 見た目を調整
             .keyboardType(.numberPad) // 数字キーのみにする
             .multilineTextAlignment(.trailing) // 右寄せ
             .frame(width: 200) // サイズを調整
+            .focused($isActive) // フォーカスを制御
+        }.toolbar{
+                ToolbarItemGroup(placement: .keyboard, content: {
+                    Spacer()
+                    Button("閉じる"){
+                        isActive = false
+                    }
+                })
+             }
     }
}
```



## 入力された値を蓄積
ユーザーから入力された金額は都度どこかに足し算をしながら蓄積していかないといけません。

今回はドキュメントディレクトリ内に「**bill.txt**」を作成してデータを読み込み、上書きしていきたいと思います。

[iOSのファイルシステム:サンドボックス構造](https://tech.amefure.com/swift-file-manager#head1)

### FileManagerを使ってファイル作成/読み込み/削除

Swiftにはファイルの操作を行える**FileManagerクラス**が用意されています。

[【Swift】FileManagerでファイルを保存！操作方法や格納場所](https://tech.amefure.com/swift-file-manager)

まずは「File」>「new」>「File...」からSwiftUIファイルを新しく生成します。名前は「FileController」としておきました。その中に記述していきます。

```swift:FileController
import Foundation

class FileController {
    
    // Documents内で操作するファイル名
    private let fileName:String = "bill.txt"
    
    
    // 保存ファイルへの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(fileName)
            return url
        } catch {
            return nil
        }
    }

    func writingFile(_ text:String) {
        guard let url = docURL() else {
            return
        }
        
        let addNum = changeNum(text)
        let pastNum = readingFile()
        var result = 0
        
        // 足し算処理
        if (pastNum != 0){
            result = addNum + pastNum
        }else{
            result = addNum
        }
        
        let strResult = String(result)
        

        do {
            // 書き込み処理
            try strResult.write(to: url,atomically: true,encoding: .utf8)
        } catch{
            print("書き込み失敗")
        }
    }
    
    // ファイル読み込み処理
    func readingFile()->Int{
        guard let url = docURL() else {
            return 0
        }
        
        do {
            let textData = try String(contentsOf: url, encoding: .utf8)
            return changeNum(textData)
        } catch {
            return 0
        }
    }
    
    // ファイル削除処理
    func clearFile() {
        guard let url = docURL() else {
            return
        }
        do {
            try FileManager.default.removeItem(at: url)
        } catch {
            
        }
    }
    
    // 文字列を数値に変換
    func changeNum(_ text:String) -> Int{
        guard let num = Int(text) else{
            return 0
        }
        return num
    }
    
    
}
```

中にFileControllerクラスを定義し、プロパティとメソッドを追加します。それぞれの役割は以下の通りです。

```swift:FileController
private let fileName:String = "bill.txt" // ファイル名
func docURL() -> URL? {} // ドキュメントディレクトリのフルパスを構築
func writingFile(_ text:String) {} // 引数に受け取ったtextを書き込み処理
func readingFile()->Int{} // 読み込み処理
func clearFile() {} // 削除処理
func changeNum(_ text:String) -> Int{} // TextFieldのString型をInt型に変換する
```

これで「bill.txt」に**格納されている金額を読み込む処理と足し算する処理と削除する処理が定義**できました。

## ビューから足し算処理を行えるようにする

ボタンをクリックされたらユーザーが入力した値をファイルへ足し算して書き込むようにしていきます。

Button構造体のアクション部分にFileControllerクラスのwritingFileメソッドを呼び出せるようにしていきます。変数にFileControllerのインスタンスを生成し、アクション内でwritingFileメソッドを呼び出します。**足し算(書き込み)処理を終えたら入力値をリセット**しておきます。

```diff_swift:ContentView
@FocusState var isActive:Bool // キーボードフォーカス
+ let fileController = FileController() // ファイルコントローラークラスをインスタンス化
```

```diff_swift:ContentView
.focused($isActive) // フォーカスを制御
// TextFieldの下側

+ Button(action: {
+           fileController.writingFile(cash)
+           let data = fileController.readingFile()
+           bill = data // 請求金額を更新
+           cash = "" // 入力値をクリア               
+                    
+ }, label: {
+           Text("登録")
+ }).padding()
+   .foregroundColor(.white)
+   .background(.orange)
+   .cornerRadius(8)

// VStackの上側         
}.toolbar{
                ToolbarItemGroup(placement: .keyboard, content: {
```

[【Swift UI】Button(ボタン)の構造や書式と使い方！actionやプロパティ](https://tech.amefure.com/swift-button)


## ビューから削除処理を行えるようにする
同様に削除処理もボタン化しておきます。

```diff_swift:ContentView
.cornerRadius(8)
// 登録Buttonの下側

+ Button(action: {
+   　       fileController.clearFile()
+           let data = fileController.readingFile()
+           bill = data
+ }, label: {
+           Text("リセット")
+ }).padding()
+   .foregroundColor(.white)
+   .background(.orange)
+   .cornerRadius(8)
   

// VStackの上側         
}.toolbar{
                ToolbarItemGroup(placement: .keyboard, content: {
```

![スクリーンショット 2022-07-06 20.46.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2264957/7a6f2c7e-bd67-4154-5091-5edd5231fe24.png)


## 割り勘計算画面の作成
再度新規のSwiftUIファイルを「**CalcBill**」という名前で作成します。この中に**割り勘された金額を計算する部分を実装**していきます。またこのファイルはContentViewから呼び出す前提で作成していきます。

まずは割り勘にする人数を選べるようにします。今回は2人〜4人のボタンを作成しておき、押すだけで人数を選択する構造にします。人数ボタンはグリッドレイアウトにしたいため``LazyVGrid``で使う用の変数``grids``と人数を格納する変数``people``も宣言しておきます。

[【Swift UI】グリッドレイアウトの実装方法！LazyVGridとGridItemの使い方](https://tech.amefure.com/swift-grid-layout)

```swift:CalcBill
var grids = Array(repeating: GridItem(.fixed(80), spacing: 20), count: 3)
@State var people:Int = 1 // 割り勘にする人数を格納
```

アイコンはXCodeにデフォルトで組み込まれている「**SF-Symbols**」を使用しています。ですがちょうど良いアイコンがなかったので``offset``を使って位置を調整してみました。

![スクリーンショット 2022-07-06 20.43.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2264957/1f5f626a-7bde-e2d8-f81a-203d57807a60.png)


[【Swift UI】SF-Symbolsの一覧の使い方！Image(systemName:)](https://tech.amefure.com/swift-sf-symbols)

```swift:CalcBill
var body: some View {
  LazyVGrid(columns: grids){
     Group {
           
           // 2人ボタン
           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)

           // 3人ボタン                      
           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)

           // 4人ボタン                     
           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()
  .border(Color.green,width: 2)
}
```



## 親ビューファイルと変数を紐付ける
親ビュー(ContentView)にある変数billの値を子ビュー(CalcBill)の変数とリンクさせます。

親と子の変数同士をリンクさせるには``@Binding``を子側に使用します。親側では呼び出す時に紐づけたい変数を引数として渡すだけです。

[【Swift UI】@Bindingの意味と使い方とは？親と子の構造体](https://tech.amefure.com/swift-binding)

```diff_swift:CalcBill
struct CalcBill: View {
        var grids = Array(repeating: GridItem(.fixed(80), spacing: 20), count: 3)
        @State var people:Int = 1 // 割り勘にする人数を格納
+       @Binding var bill:Int     // 請求金額 親とバインディング
```

```diff_swift:CalcBill
struct CalcBill: View {
+ VStack {
+               HStack {
+                   Text("合計：")
+                   Text("¥\(bill)").font(.custom("AppleSDGothicNeo-SemiBold", size: 50)).foregroundColor(.gray)
+               }.padding()
+               HStack {
+                   Text("\(people)人で割ると.....").foregroundColor(.gray)
+               }.padding()
+               HStack {
+                   Text("1人：")               
+                   Text("¥\(bill / people)").font(.custom("AppleSDGothicNeo-SemiBold", size: 50)).foregroundColor(.gray)
+               }.padding()
    LazyVGrid(columns: grids){
       // 先ほどの処理
    }
+ }.frame( maxWidth: .infinity, maxHeight: .infinity)
+  .background(Color(red: 0.9, green: 0.9, blue: 0.9))
+  .ignoresSafeArea()
}
```
![スクリーンショット 2022-07-06 20.53.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2264957/38caa3f5-2da6-e2a2-59d5-efefd8cf9a84.png)

## 親側から子をタブページとして呼び出す
+ ContentView(入力ビュー)
+ CalcBill(計算ビュー)

この2つのページはタブページとして切り替えやすくしたいので``TabView``を使って実装していきます。そのために変数``selectedTag``を用意してタブ番号を格納できるようにしておきます。

[【Swift UI】TabViewの使い方とtabItemの色を変更する方法！](https://tech.amefure.com/swift-tabview)

```diff_swift:ContentView
+ @State var selectedTag = 1   //  タブビュー
@FocusState var isActive:Bool // キーボードフォーカス
```

```diff_swift:ContentView
+ TabView(selection: $selectedTag){
+           VStack {
+              // TextFiledやButton部分
+           }.tabItem{
+                   Image(systemName: "pencil.circle")
+                   Text("Add")
+           }.tag(1)
+               .frame( maxWidth: .infinity, maxHeight: .infinity)
+               .ignoresSafeArea()

            // 割り勘計算View
+           CalcBill(bill: $bill).tabItem{
+               Image(systemName: "yensign.circle")
+               Text("Calc")
+           }.tag(2)
+       }
+       .ignoresSafeArea()
+       .accentColor(.orange)
        .toolbar{
            ToolbarItemGroup(placement: .keyboard, content: {
                Spacer()
                Button("閉じる"){
                    isActive = false
                }
            })
        }
```
最後の``.toolbar``の部分はVStackにかかっていたのをTabViewに移動させておいてください。これでボタンをクリックするとタブ(View)が切り替わる機能を実装することができました。

![happy bithday.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2264957/ef62d18e-4782-901b-2289-e95cc7c654ef.png)

## 作ってみた感想

[Swift UIで燃費計算アプリを作ってみた！](https://qiita.com/amefure/items/b7d927e78dcae15e98e3)
↑前回作ったアプリと比べると少しレベルの上がったアプリ開発ができたかなと思います。

レイアウトを調整できるグリッドレイアウトやタブビュー、ファイルを操作するFileManagerなど実用的なものの使い方への理解が深まった気がします。

しかしアプリと言うにはデザインも機能もお粗末なものなのでもう少し改良して行けたら良いなと思っています。

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

最後に今回のソースコードを全てのせておきます。

## ソースコード
```swift:ContentView
struct ContentView: View {
    
    @State var selectedTag = 1   //  タブビュー
    @FocusState var isActive:Bool // キーボードフォーカス
    let fileController = FileController() // ファイルコントローラークラスをインスタンス化
    
    @State var bill:Int = 0      // 請求金額
    @State var cash:String = ""  // 入力された金額情報

    var body: some View {
        
        TabView(selection: $selectedTag){
            VStack {
                Text("¥\(bill)").font(.custom("AppleSDGothicNeo-SemiBold", size: 50)).foregroundColor(.gray).lineLimit(1)
                

                TextField("¥",text: $cash)
                     .keyboardType(.numberPad)
                     .textFieldStyle(RoundedBorderTextFieldStyle())
                     .focused($isActive)
                     .multilineTextAlignment(.trailing)
                     .frame(width: 200)
                    
                Button(action: {
                    fileController.writingFile(cash)
                    let data = fileController.readingFile()
                    bill = data // 請求金額を更新
                    cash = "" // 入力値をクリア
                    
                }, label: {
                    Text("登録")
                }).padding()
                    .foregroundColor(.white)
                    .background(.orange)
                    .cornerRadius(8)
                
                Button(action: {
                    fileController.clearFile()
                    let data = fileController.readingFile()
                    bill = data
                }, label: {
                    Text("リセット")
                }).padding()
                    .foregroundColor(.white)
                    .background(.orange)
                    .cornerRadius(8)

                
            }.tabItem{
               
                    Image(systemName: "pencil.circle")
                    Text("Add")
                
            }.tag(1)
                .frame( maxWidth: .infinity, maxHeight: .infinity)
                .ignoresSafeArea()
                
            // 割り勘計算View
            CalcBill(bill: $bill).tabItem{
                Image(systemName: "yensign.circle")
                Text("Calc")
            }.tag(2)
        }
        .ignoresSafeArea()
        .accentColor(.orange)
        .toolbar{
            ToolbarItemGroup(placement: .keyboard, content: {
                Spacer()
                Button("閉じる"){
                    isActive = false
                }
            })
        }
        
    }
}
```

```swift:FileController
import Foundation


// 請求金額を蓄積するためのFileController
class FileController {
    
    // Documents内で操作するファイル名
    private let fileName:String = "bill.txt"
    
    
    // 保存ファイルへの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(fileName)
            return url
        } catch {
            return nil
        }
    }

    func writingFile(_ text:String) {
        guard let url = docURL() else {
            return
        }
        
        let addNum = changeNum(text)
        let pastNum = readingFile()
        var result = 0
        
        if (pastNum != 0){
            result = addNum + pastNum
        }else{
            result = addNum
        }
        
        let strResult = String(result)
        

        do {
            // 書き込み処理
            try strResult.write(to: url,atomically: true,encoding: .utf8)
        } catch{
            print("書き込み失敗")
        }
    }
    
    // ファイル読み込み処理
    func readingFile()->Int{
        guard let url = docURL() else {
            return 0
        }
        
        do {
            let textData = try String(contentsOf: url, encoding: .utf8)
            return changeNum(textData)
        } catch {
            return 0
        }
    }
    
    // ファイル削除処理
    func clearFile() {
        guard let url = docURL() else {
            return
        }
        do {
            try FileManager.default.removeItem(at: url)
        } catch {
            
        }
    }
    
    // 文字列を数値に変換
    func changeNum(_ text:String) -> Int{
        guard let num = Int(text) else{
            return 0
        }
        return num
    }
    
    
}
```

```swift:CalcBill
struct CalcBill: View {
        var grids = Array(repeating: GridItem(.fixed(80), spacing: 20), count: 3)
        @State var people:Int = 1 // 割り勘にする人数を格納
        @Binding var bill:Int     // 請求金額 親とバインディング
        
        var body: some View {
            VStack {
                HStack {
                    Text("合計：")
                    Text("¥\(bill)").font(.custom("AppleSDGothicNeo-SemiBold", size: 50)).foregroundColor(.gray)
                }.padding()
                HStack {
                    Text("\(people)人で割ると.....").foregroundColor(.gray)
                }.padding()
                HStack {
                    Text("1人：")
                
                    Text("¥\(bill / people)").font(.custom("AppleSDGothicNeo-SemiBold", size: 50)).foregroundColor(.gray)
                }.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()
                .border(Color.green,width: 2)
                
                
            }.frame( maxWidth: .infinity, maxHeight: .infinity)
                .background(Color(red: 0.9, green: 0.9, blue: 0.9))
                .ignoresSafeArea()
                
            
        }
}

//　プレビューように初期値を渡さないとエラーになります
struct CalcBill_Previews: PreviewProvider {
    static var previews: some View {
        CalcBill(bill: Binding.constant(1000))
            .previewInterfaceOrientation(.portrait)
    }
}

```