見出し画像

iPhone アプリを自分でつくる 38.



今回の内容: Tab を使用してアプリを作成する 5.


前回の ToolBox アプリの続きです。

前回は メモアプリを Tab に組み込みました。
そしてシミュレータでリストの順番を入れ替えられるようにしました。
また編集用のシートを Toggle で表示させることができました。

今回は、編集用のシートに表示させる view を作成します。

現状のコード

前回37回最後のコード(TabIconsView と MemoView ファイル)とその前々回35回の最後のコードとなります。


TextField

TextEditor

まず MemoDetailView の下に EditSheetView を作成します。
メモ情報を発信している MemoViewModel とやり取りできるように @EnvironmentObject をセットします。
またイニシャライズ時に受け取るメモが必要なので var memo: Memo とします。
まずはText("editing: \(memo.memoTitle)") としてうまくプロパティ情報が受け取ることができるかみてみます。

struct EditSheetView: View {
    @EnvironmentObject var memoVM: MemoViewModel
    var memo: Memo
    
    var body: some View {
        Text("editing: \(memo.memoTitle)")
    }
}

今度は受け取ったメモを編集できるようにタイトルを TextField で受け、内容をTextEditor で受けられるようにコードを書きます。
それぞれ $ バインディングが必須となります。

struct EditSheetView: View {
    @EnvironmentObject var memoVM: MemoViewModel
    @State var memo: Memo
    @State var title: String
    @State var content: String
    
    var body: some View {
        TextField("タイトル", text: $title)
            .padding()
        TextEditor(text: $content)
            .padding()
    }
}

EditSheetView を MemoDetailView で使用します。
渡す引数は memomemo.memoTitleおよび memo.memoContent です。

//  MemoDetailView

        .sheet(item: $editMemo, onDismiss: { editingMode = false }) { memo in
            EditSheetView(memo: memo, title: memo.memoTitle, content: memo.memoContent)
        }
    }
}

これで編集画面で文字を編集することができますが、登録はまだできません。
toolbar を使って画面下に登録ボタンを作りたいと思います。
.toolbar { ToolbarItem(placement: .bottomBar) { }}  を作っておいて
func editMemo() {  } までコードを書きました。
ここでシミュレータで確認してみます。

struct EditSheetView: View {
    @EnvironmentObject var memoVM: MemoViewModel
    @State var memo: Memo
    @State var title: String
    @State var content: String
    
    var body: some View {
        VStack {
            TextField("タイトル", text: $title)
                .padding()
            TextEditor(text: $content)
                .padding()
        }
        .toolbar {
            ToolbarItem(placement: .bottomBar) {
                Button("登録", action: { })
            }
        }
    }
    
    func editMemo() {
        
    }
}

しかし登録ボタンが出てきません。
これはなぜでしょう?
toolbar はNavigation のもとにつけることができますが、考えてみれば、この EditSheetView はNavigationStack の子供にはなっていないです!
第36回で行った MemoDetailView では、
NavigationStack -> NavigationLink を使っての MemoDetailView となっており、 NavigationStack の影響下に置かれていました。
しかし、このEditSheetView はシートでの表示、つまり editingMode = true になったことをきっかけに現れる新たな画面であって、NavigationStack の影響下にはないです。
そこで NavigationStack をおいてあげると登録ボタンも表示されました。


次はButton のaction: { } の中身と そのメソッドを書きます。
editMemo メソッドとし、最初に(現在入力している)タイトルとコンテンツの空白部分を取り除きます。
次に少なくとも一方には値があることを確認してから、もともとのメモ内容から変化があることを確認します。
そうしたら新しい内容に書き換えます。

// struct EditSheetView: View {

// ~ ~ ~ 
        
            .toolbar {
                ToolbarItem(placement: .bottomBar) {
                    Button("登録", action: { editMemo() })
                }
            }
        }
    }
    
    func editMemo() {
        let newTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
        let newContent = content.trimmingCharacters(in: .whitespacesAndNewlines)

        if !newTitle.isEmpty || !newContent.isEmpty {
            if let index = memoVM.memos.firstIndex(where: { $0.id == memo.id }) {
                if memoVM.memos[index].memoTitle != newTitle || memoVM.memos[index].memoContent != newContent {
                    memoVM.memos[index].memoTitle = newTitle
                    memoVM.memos[index].memoContent = newContent
                }
            }
        }
        dismiss()
    }
}

編集を実行することができました。
が、編集後にリストに戻るときのMemoDetailView では変更が反映されていませんリストに戻ってからもう一度MemoDetailView をみると変更は反映されています。

なぜ反映されないかを図にしてみます。

上の図のように シートで表示された EditSheetView は情報発信源のMemoViewModel を編集しますが、MemoDetailView までは繋がっていません。
MemoView の List は情報発信源の情報変更を受ける度に @StateObject = MemoViewModel() として作り変えられますが、MemoDetailView は List でのTapによってイニシャライズされるのを待つからです。
ではもし MemoDetailView も MemoViewModel() のように変化によってイニシャライズされるようにすればいいんじゃない?かというと、情報を取るタイミングによってそれぞれのview が違う値を持つ可能性がでてきてしまいます。
情報受信責任者が2つになってしまい混乱する可能性があるのです。


つまり現状の方法では EditSheetView が閉じられたときに MemoDetailView の view の書き換えがされていないのは避けられないのです。
そこで MemoDetailView は、編集が終わったら戻らずにリストが現れるよう(つまりEditSheetView が閉じるとMemoDetailView も閉じるよう)にすれば違和感はなくなります。

// MemoDetailView

        .sheet(item: $editMemo, onDismiss: {
            editingMode = false
            dismiss()
        }) { memo in
            EditSheetView(memo: memo, title: memo.memoTitle, content: memo.memoContent)
        }

onDismiss の際に エディットボタンをオフにするとともに dissmiss() でディスミスするようにセットします。
これで、EditSheetView で登録ボタンを押すと MemoDetailView は editingMode をOff にした後、引っ込みます。



編集ルートを制限する

あと気になるのは、今回のEditSheetView というview が、情報発信をしている MemoViewModel の情報に直接メソッドで変更を仕掛けているところです。
見返すと AddMemoView についても MemoVM.memos.append(Memo(~)という形で直接情報源を操作しています。
そもそも MemoViewModel の memos は didSet { saveMemos() } とされていて、ここを触ることで登録、変更が完了するシステムなので、全方向から変更できてしまう状態になっています。
これだと、なにか不具合があった場合に発生原因を捜索するのに困るかもしれません。
そこで、Memo の Add(追加) と Edit(編集) はMemoViewModel の中にメソッドを作り、実行ルートを限るようにします。


まずは MemoViewMedel の didSet { saveMemos() } を消しておきます。

class MemoViewModel: ObservableObject {
    @Published var memos: [Memo] = []

次に、追加メソッドと 編集メソッドを作成します。
追加は addMemo(memo: Memo) としてmemo をそのままもらうようにします。
編集は editMemo(id: UUID, title: String, content: String) とします。
 memos の中から合致する memo を探し出すために id を使うためにこうしています。

// MemoViewModel

    func addMemo(memo: Memo) {
        memos.append(memo)
        saveMemos()
    }
    
    func editMemo(id: UUID, title: String, content: String) {
        if let index = memos.firstIndex(where: { $0.id == id }) {
            if memos[index].memoTitle != title || memos[index].memoContent != content {
                memos[index].memoTitle = title
                memos[index].memoContent = content
                saveMemos()
            }
        }
    }

あとは、このメソッドをそれぞれのview から呼び出します。

AddMemoView の 登録ボタンでは、まず タイトルとコンテンツに何らかの記入があることを確認してから Memo オブジェクトを作り memoVM のaddMemo()メソッドを呼び出しています。
また @Environment(\.dismiss) var dismiss を置いてディスミスされるようにしました。

struct AddMemoView: View {
    @EnvironmentObject var memoVM: MemoViewModel
    @Environment(\.dismiss) var dismiss
    @State private var memoTitle = ""
    @State private var memoContent = ""

    var body: some View {
        TextField("タイトル", text: $memoTitle)
            .textFieldStyle(.roundedBorder)
            .padding()
        
        TextEditor(text: $memoContent)
            .border(.gray)
            .padding()
        
        Button("登録") {
            if !memoTitle.isEmpty && !memoContent.isEmpty {
                let memo = Memo(memoTitle: memoTitle, memoContent: memoContent)
                memoVM.addMemo(memo: memo)
            }
            dismiss()
        }
    }
}

EditSheetView では、登録ボタンのアクションはそのままにしておいて、editMemoメソッドを使いその中で memoVM.editMemo() を呼び出して登録するようにしました。

// EditSheetView

            .toolbar {
                ToolbarItem(placement: .bottomBar) {
                    Button("登録", action: { editMemo() })
                }
            }
        }
    }
    
    func editMemo() {
        let newTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
        let newContent = content.trimmingCharacters(in: .whitespacesAndNewlines)

        if !newTitle.isEmpty && !newContent.isEmpty {
            memoVM.editMemo(id: memo.id, title: newTitle, content: newContent)
        }
        dismiss()
    }
}

今回、追加と編集のルートを絞ることができましたが、これは今後、編集ルールを守ることで成り立ちます。
新しい構造体を作ったときに直接 MemoViewModelのmemos を変更することもできてしまいます。
アプリの大きさや作成の状況によって didSet { save() } のような方法が良い場合もあると思います。
今回のようなアプリは、個人で自分のためにアプリを作成する、ということを考えれば didSet { save() } のような方法をとるのも手軽で良いと感じます。


Keyboard にボタンをつける


シミュレータのキーボードを立ち上げた際に入力が終わっても引っ込めるボタンがありません。
第16回でもNumberキーボードで行いましたが、キーボードにボタンをつけて引っ込められるようにします。
まずAddMemoView で行います。
toolbar を使用してキーボードにボタンをつけますので、NavigationStack を使用して、toolbar { ToolbarItemGroup(placement: .keyboard) { } とします。

@FocusState でどのフィールドにカーソルが当たっているか検知できるようにしてキーボードの"Done"ボタンでカーソルが外れてキーボードが引っ込むようになりました。

struct AddMemoView: View {
    @EnvironmentObject var memoVM: MemoViewModel
    @Environment(\.dismiss) var dismiss
    @State private var memoTitle = ""
    @State private var memoContent = ""
    @FocusState private var titleIsFocused: Bool
    @FocusState private var contentIsFocused: Bool

    var body: some View {
        NavigationStack {
            TextField("タイトル", text: $memoTitle)
                .textFieldStyle(.roundedBorder)
                .padding()
                .focused($titleIsFocused)
            
            
            TextEditor(text: $memoContent)
                .border(.gray)
                .padding()
                .focused($contentIsFocused)
            
            Button("登録") {
                if !memoTitle.isEmpty && !memoContent.isEmpty {
                    let memo = Memo(memoTitle: memoTitle, memoContent: memoContent)
                    memoVM.addMemo(memo: memo)
                }
                dismiss()
            }
            .toolbar {
                ToolbarItemGroup(placement: .keyboard) {
                    HStack {
                        Spacer()
                        Button("Done") {
                            titleIsFocused = false
                            contentIsFocused = false
                        }
                    }
                }
            }
        }
    }
}

EditSheetView でも行います。

struct EditSheetView: View {
    @EnvironmentObject var memoVM: MemoViewModel
    @Environment(\.dismiss) var dismiss
    @State var memo: Memo
    @State var title: String
    @State var content: String
    @FocusState private var titleIsFocused: Bool
    @FocusState private var contentIsFocused: Bool
    
    var body: some View {
        NavigationStack {
            VStack {
                TextField("タイトル", text: $title)
                    .padding()
                    .focused($titleIsFocused)
                TextEditor(text: $content)
                    .padding()
                    .focused($contentIsFocused)
            }
            .toolbar {
                ToolbarItem(placement: .bottomBar) {
                    Button("登録", action: { editMemo() })
                }
            }
            .toolbar {
                ToolbarItemGroup(placement: .keyboard) {
                    HStack {
                        Spacer()
                        Button("Done") {
                            titleIsFocused = false
                            contentIsFocused = false
                        }
                    }
                }
            }
        }
    }
    
    func editMemo() {
        let newTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
        let newContent = content.trimmingCharacters(in: .whitespacesAndNewlines)

        if !newTitle.isEmpty && !newContent.isEmpty {
            memoVM.editMemo(id: memo.id, title: newTitle, content: newContent)
        }
        dismiss()
    }
}

その他、シートにキャンセルボタンもつけますが、最後のデザイン調整で行っていきます。

今回はここまでです。
次回は全体のデザインの調整を進めていく予定です。


まとめ

いかがでしたでしょうか?
ちょっとしたことを修正するだけでも、いろいろ考えることが発生して理解に役立ちます。
できているものを修正するのは、面倒なことでもありますが、今後進めていく上ではプラスになります。
どんどんトライしましょう!


次回第39回内容
  次回は Tab を使用してアプリを作成する 6. です。
          よろしくお願いします。


今回までのコード


下に TabIconsView と MemoView ファイル を記載しています。
下記以外のファイルは35回の最後のコードとなります。

import SwiftUI

struct TabIconsView: View {
    
    var body: some View {
        TabView {
            FontsView()
                .tabItem {
                    Image(systemName: "f.square")
                }
            
            FontSamplesView()
                .tabItem {
                    Image(systemName: "f.cursive.circle")
                }
            
            ColorPickerView()
                .tabItem {
                    Image(systemName: "rainbow")
                }
            
            MemoView()
                .tabItem {
                    Image(systemName: "note")
                }
        }
    }
}

#Preview {
    TabIconsView()
}
import SwiftUI

struct MemoView: View {
    @StateObject var memoVM = MemoViewModel()
    @State private var showingAddSheet = false
    
    var body: some View {
        NavigationStack {
            List($memoVM.memos, editActions: .all) { $memo in
                NavigationLink(value: memo) {
                    Text(memo.memoTitle)
                }
            }
            .navigationDestination(for: Memo.self) { memo in
                MemoDetailView(memo: memo)
            }
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button {
                        showingAddSheet = true
                    } label: {
                        Image(systemName: "plus")
                    }
                }
            }
            .sheet(isPresented: $showingAddSheet) {
                AddMemoView()
            }
        }
        .environmentObject(memoVM)
    }
}

struct AddMemoView: View {
    @EnvironmentObject var memoVM: MemoViewModel
    @Environment(\.dismiss) var dismiss
    @State private var memoTitle = ""
    @State private var memoContent = ""
    @FocusState private var titleIsFocused: Bool
    @FocusState private var contentIsFocused: Bool

    var body: some View {
        NavigationStack {
            TextField("タイトル", text: $memoTitle)
                .textFieldStyle(.roundedBorder)
                .padding()
                .focused($titleIsFocused)
            
            TextEditor(text: $memoContent)
                .border(.gray)
                .padding()
                .focused($contentIsFocused)
            
            Button("登録") {
                if !memoTitle.isEmpty && !memoContent.isEmpty {
                    let memo = Memo(memoTitle: memoTitle, memoContent: memoContent)
                    memoVM.addMemo(memo: memo)
                }
                dismiss()
            }
            .toolbar {
                ToolbarItemGroup(placement: .keyboard) {
                    HStack {
                        Spacer()
                        Button("Done") {
                            titleIsFocused = false
                            contentIsFocused = false
                        }
                    }
                }
            }
        }
    }
}

struct MemoDetailView: View {
    @State private var editingMode = false
    @State private var editMemo: Memo? = nil
    @Environment(\.dismiss) var dismiss
    var memo: Memo
    
    var body: some View {
        VStack {
            Text(memo.memoTitle)
            Text(memo.memoContent)
            
            Toggle("編集モード", isOn: $editingMode)
                .padding()
        }
        .onChange(of: editingMode) {
            if editingMode {
                editMemo = memo
            }
        }
        .sheet(item: $editMemo, onDismiss: {
            editingMode = false
            dismiss()
        }) { memo in
            EditSheetView(memo: memo, title: memo.memoTitle, content: memo.memoContent)
        }
    }
}

struct EditSheetView: View {
    @EnvironmentObject var memoVM: MemoViewModel
    @Environment(\.dismiss) var dismiss
    @State var memo: Memo
    @State var title: String
    @State var content: String
    @FocusState private var titleIsFocused: Bool
    @FocusState private var contentIsFocused: Bool
    
    var body: some View {
        NavigationStack {
            VStack {
                TextField("タイトル", text: $title)
                    .padding()
                    .focused($titleIsFocused)
                TextEditor(text: $content)
                    .padding()
                    .focused($contentIsFocused)
            }
            .toolbar {
                ToolbarItem(placement: .bottomBar) {
                    Button("登録", action: { editMemo() })
                }
            }
            .toolbar {
                ToolbarItemGroup(placement: .keyboard) {
                    HStack {
                        Spacer()
                        Button("Done") {
                            titleIsFocused = false
                            contentIsFocused = false
                        }
                    }
                }
            }
        }
    }
    
    func editMemo() {
        let newTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
        let newContent = content.trimmingCharacters(in: .whitespacesAndNewlines)

        if !newTitle.isEmpty && !newContent.isEmpty {
            memoVM.editMemo(id: memo.id, title: newTitle, content: newContent)
        }
        dismiss()
    }
}


struct Memo: Identifiable, Hashable, Codable {
    var id = UUID()
    var memoTitle: String
    var memoContent: String
    
    static let sampleMemos: [Memo] = [
        Memo(memoTitle: "Title サンプル1", memoContent: "Contents サンプル1"),
        Memo(memoTitle: "Title サンプル2", memoContent: "Contents サンプル2"),
        Memo(memoTitle: "Title サンプル3", memoContent: "Contents サンプル3"),
        Memo(memoTitle: "Title サンプル4", memoContent: "Contents サンプル4"),
        Memo(memoTitle: "Title サンプル5", memoContent: "Contents サンプル5")
    ]
}

class MemoViewModel: ObservableObject {
    @Published var memos: [Memo] = []
    
    let savePath = URL.documentsDirectory.appending(path: "SaveMemoData")
    
    init() {
        setMemos()
    }
    
    func setMemos() {
        if let data = try? Data(contentsOf: savePath) {
            if let result = try? JSONDecoder().decode([Memo].self, from: data){
                memos = result
            }
        } else {
            memos = []
            memos.append(contentsOf: Memo.sampleMemos)
        }
    }

    func saveMemos() {
        do {
            let encodedMemos = try JSONEncoder().encode(memos)
            try encodedMemos.write(to: savePath, options: [.atomic, .completeFileProtection])
        } catch {
            print(error.localizedDescription)
        }
    }
    
    func addMemo(memo: Memo) {
        memos.append(memo)
        saveMemos()
    }
    
    func editMemo(id: UUID, title: String, content: String) {
        if let index = memos.firstIndex(where: { $0.id == id }) {
            if memos[index].memoTitle != title || memos[index].memoContent != content {
                memos[index].memoTitle = title
                memos[index].memoContent = content
                saveMemos()
            }
        }
    }
}

#Preview {
    MemoView()
        .environmentObject(MemoViewModel())
}

以上です

いいなと思ったら応援しよう!

MasaOno
サポートいただければ、さらに活動を広げることができます!