SwiftUIを学んでいく過程でポートフォリオがてら作成した「割り勘アプリ」の作成手順と方法をまとめていきます。
初心者の方でも同じものが作れるように解説できたらなと考えています。
また至らぬ点や拙いところもあると思いますが優しく教えていただけると嬉しいです。
今回↓前回の記事で作成したアプリを改良していきます。
今回作っていくアプリ
割り勘アプリ 改良ver
● 追加する機能
- メモも入力可能にする
- 入力された情報をリスト表示
- リストから情報の削除を可能に
- 割り勘できる人数にカスタムを追加
- アプリを落としても情報を保持
● 環境
- Swift
- SwiftUI
- Xcode
- Macbook M1チップ
● 作成(編集)するファイル
- ContentView.swift:入力ビュー
- CalcBillView.swift:計算ビュー※名前変更しました
- FileController.swift:ファイル操作クラス
- CashData.swift:構造体とクラスを定義
- ListCashView:1行を元にリスト表示するビュー
- RowCashView.swift:リスト表示の1行を定義
アプリ制作の手順
- 前回の記事まで作成
- CashData.swiftファイルを作成しJSONファイルと紐付ける構造体を作成
- FileController.swiftのクラスをJSON用に修正
- RowCashView.swiftファイルを作成しユーザーの情報をリスト表示するための1行を作成
- ListCashView.swiftファイルを作成しRowCashViewを基にリスト表示するビューの作成
- CalcBillView.swiftに人数をカスタムで入力できるように修正
- ContentView.swiftの調整
- アプリアイコンのセット
JSONと紐付く構造体の定義
前回は「bill.txt」に金額だけをそのまま記述していましたが、今回はユーザーが入力できる情報を「金額」と「メモ」に増やしたいのでファイルのデータの書き込みを下記のようなJSON形式にします。ついでにファイル名も「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」を作成し、中に以下のように記述します。
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の使い方
日付の整形方法についてはこちらの記事をご覧ください。
【Swift】DateFormatterの使い方!書式や日付形式の調整方法
ポイント
- Swiftでは構造体をJSON形式に変換可能
- 一意にしたいプロパティにはUUID()
- Listで表示したい構造体にはIdentifiableプロトコルの準拠が必須
- JSON形式へ変換する構造体にはCodableプロトコルの準拠が必須
JSONファイルとして保存する
構造体の定義を終えたら、JSON形式に変換してファイルに保存していく処理を記述します。前回の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データを構造体に変換
ポイント
- JSONEncoder/JSONDecoderの扱い
- JSONデータを扱う際の文字コード:UTF-8のData型への変換
- 構造体との紐付け方法
- 配列に格納された構造体([構造体])形式への追加処理
JSONファイルを操作する処理に関してはこちらをご覧ください。
【Swift】JSONデータをエンコードする方法!JSONEncoderクラスの使い方
【Swift】JSONデータをデコードする方法!JSONDecoderクラスの使い方
ここではコードのみ記載しておきます。中身は以下の通りです。
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行を定義する
新規に「RowCashView.swift」を作成しここにリスト表示させる1行単位のデザインを記述していきます。今回は上記のようにします。
ポイント
- offset(x: y:)で表示位置を微調整
- Rectangleで縦線を表現
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行を元にリスト表示させるビューを作成していきます。
ポイント
- @ObservedObjectを使ったクラスの監視
- 親のメソッドを受け取るための空の関数の用意
- スワイプアクションで削除可能に
【Swift UI】@ObservedObjectの意味と使い方!クラスとプロトコルとの関係
【SwiftUI】親ビューのメソッドを子ビューで呼び出す方法
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: {})
}
}
割り勘できる人数にカスタム値を追加
ポイント
- TextFieldを使ってカスタム欄の追加
- 子ビューになるキーボードのフォーカスは親から制御
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(親ビュー)からリストの呼び出しや修正
ポイント
- ダークモード対応
- NavigationLinkの有効/無効
- NavigationLinkからリストビューの呼び出し
- 数字のみのキーボードへの閉じるボタンの実装
【Swift UI】ダークモード時にテキストの文字色を変更する方法!
【SwiftUI】NavigationLinkを条件によって画面遷移させる方法
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コードの扱いとファイルをデータベースのように扱う流れはとても勉強になった気がします。
ご覧いただきありがとうございました。