macOS   SwiftUIプログラミング   ToDo

ホーム

【お知らせ】
SwiftUIで作った macOS Todo アプリ ToDone を100ダウンロードまで無料にしました。マニュアルページは、ToDone サポートページ です。

【本文】
この章では、リスト(List)という View を使って、ToDo を作ります。

このコーナーでは、任意のテキストエディタでコードを記述し、 ターミナルを使ってビルドする方法で作業を進めています。Xcode で作業を進める場合は、Xcodeで作業する場合 をご一読ください。

なお、ターミナルで作業する場合も、Swift コンパイラや SwiftUI フレームワークなどを Mac にインストールするために Xcode をインストールして、一度起動させなければなりません。インストールが終われば Xcode は終了しても大丈夫です。

また、作成したアプリケーションを App Storeに提出するためのファイルにするには、Xcode を使わなければならなかったかもしれません。どこかで Xcode を使わずに作る方法を見たような気もするのですが...

私の開発環境は次のとおりです。

  • MacBook Air 2018年モデル、メモリ8G
  • macOS Monterey 12.4
  • Xcode 13.4.1
  • Swift 5.6.1

更新履歴
2022/07/13「新規」機能を追加しました。
2022/07/14「保存」と「読込」の機能を付けました。
2022/07/15「「Xcode ターミナル両用コード」を追加しました。
2022/07/15「ターミナル専用コード」を追加しました。
2022/07/16 コード説明をつけました。
2022/07/18 「編集機能付き ToDO」を追加しました。
2022/07/20 「編集機能付き ToDO」に削除確認ダイアログをつけしました。

ToDo

Xcode で作業している場合は、ContentView(ContentView.swift) に次のコードを記述します。

このコードは、ターミナルと Xcode の両用のコードです。ターミナルでビルドした場合は、ユーザーの書類フォルダの中にデータファイル(todo.txt)が作成されます。

Xcode でビルドした場合は、SandBox 内にデータファイル(todo.txt)が作成されます。SandBox の場所は、
/Users/ユーザー名/Library/Containers/com.yourname.Foo/Data/Documents/todo.txt になります。

View.swift


/************************************
    View.swift
    copyright    :   vivacocoa.jp
    last modified:   Jul. 15, 2022
************************************/

import SwiftUI

struct ContentView: View {
    @State var new = ""
    @State var todos: [String] = []

    var body: some View {
        VStack {
            HStack {
                TextField("New Todo", text: $new)
                Button(action: {
                    if new != "" {
                        todos.insert(new, at: 0)
                        new = ""
                        save()
                    }
                }, label: {Text("Add")})
            }
            List {
                ForEach(todos, id:\.self) { todo in Text(todo)}
                .onDelete(perform: { idx in todos.remove(atOffsets: idx); save() })
                .onMove(perform: {idx, n in todos.move(fromOffsets:idx, toOffset: n); save()})
            }
            // .toolbar {Button(action: {}, label: {Text("Edit")})}
        }
        .padding(10)
        .frame(minWidth: 300, maxWidth: .infinity, minHeight: 200, maxHeight: .infinity)
        .onAppear() { read() }
        .onDisappear(){ NSApplication.shared.terminate(self) }
    }
    func save() {
        var tmp:String = ""
        for str in todos {
            tmp += str + "\n"
        }
        let result = tmp.dropLast()
        guard let dirURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
            .first else { fatalError("フォルダのURLが取得できません。") }
        let fileURL = dirURL.appendingPathComponent("todo.txt")
        do {
            try result.write(to: fileURL, atomically: true, encoding: .utf8)
        } catch {
            //print("Error: \(error)")
            fatalError("ファイルが作成できません。")
        }
    }
    func read() {
        guard let dirURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
            .first else { fatalError("フォルダのURLが取得できません。")
        }
        let fileURL = dirURL.appendingPathComponent("todo.txt")
        if FileManager.default.fileExists(atPath: fileURL.path) {
            guard let fileContents = try? String(contentsOf: fileURL, encoding: .utf8)
            else { fatalError("ファイルが読み込めません") }
            todos = fileContents.components(separatedBy: "\n")
        }
    }
}
    

コード説明

  1. @State var new = ""
    new プロパティはテキストフィールドとバインドされるプロパティです。

  2. @State var todos: [String] = []
    todos プロパティは、リストに表示されるデータの本体です。

  3. HStack {
    テキストフィールドとボタンを横に並べるために HStack で囲みます。

  4. TextField("New Todo", text: $new)
    第1引数の文字列はテキストフィールドに何も入力されていない時に薄い色で表示される文字列です。第2引数にはバインドするプロパティを指定します。

  5. Button(action: {if new != "" {todos.insert(new, at: 0); new = ""; save()}
    new が空文字列でない場合は、todos 配列の先頭に new の文字列を挿入します。その後 new を空文字列に戻して、保存メソッドを呼び出します。保存メソッドについては後述します。

  6. List {ForEach(todos, id:\.self) { todo in Text(todo) }}
    List { } でリストを作成します。リストの各項目は { } の中で設定した View になります。ここでは ForEach で todos 配列から要素を一つずつ取り出して、その要素を Text ビューとしてリストの項目にしています。本来 View が期待される位置に式や文を記述できません。しかし、ForEach はその位置に記述できます。大文字で始まっていることからもわかるように ForEach は構造体です。Swift の forEach は小文字で始まっていることからわかるようにメソッドです。ForEach は構造体ですが、View プロトコルを採用しているわけではありません。コレクション(配列などのこと)から View を作成するものとして、View が期待される位置に記述できます。

  7. .onDelete(perform: { idx in todos.remove(atOffsets: idx); save() })
    リスト全体にでなく、リスト項目にデリート(削除)処理を記述します。処理内容をクロージャーで書きましたが、メソッドとして独立して記述することもできます。「ターミナル専用コード」のコメントアウト部分を参考にしてください。どちらの場合も、最後に save(保存)しています。

  8. .onMove(perform:{idx, n in todos.move(fromOffsets:idx, toOffset: n); save()})
    .onMove には、リスト項目をマウスで移動させた場合の処理を設定します。こちらも処理内容をクロージャーで記述しましたが、メソッドとして独立して記述することもできます。「ターミナル専用コード」のコメントアウト部分を参考にしてください。どちらの場合も、最後に save(保存)しています。

  9. .onAppear() { read() }
    最上層の View(つまりウィンドウ)が現れる時に read()(データの読み込み処理)をします。read() メソッドについては後述します。

  10. func save() {var tmp:String = ""; for str in todos { tmp += str + "\n"}
    保存(save)メソッドです。まず一時使用の tmp 変数を宣言します。続いて for 文を使って todos 配列から要素を一つずつ取り出して、tmp に追加していきます。その際に最後に \n(改行コード)が入る様にします。

  11. let result = tmp.dropLast()
    tmp 変数から最後の文字、つまり最後の改行コードを削除したものを result 定数に代入しています。

  12. guard ・・・処理・・・ else { fatalError("フォルダのURLが取得できません。") }
    guard は「処理の部分」に失敗すると、else 節を実行して、処理を抜ける構文です。else 節は必ず記述しなければなりません。fatalError はコンソールに引数の文字列を Fatal error として表示します。ただしこの Fatal error は、表示はしますが、アプリを強制終了するわけではありません。
     guard を使うと、この処理は失敗する可能性があることを示唆することになります。実際にディスクがロックされている場合などにはビルドは成功しても実行時に失敗します。

  13. let dirURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
    FileManager はファイルやディレクトリを操作するクラスです。default.urls を呼び出すことによって指定した場所への URL を作成できます。for 引数では「書類」フォルダを指定しています。in 引数の userDomainMask はコンピューターのユーザー用のドメイン形式で URL を作ることを指定しています。URL の形式はユーザーやコンピューター全体やインターネット上でそれぞれ形が違います。結果は配列で返ってきますので、first で配列の最初の要素を取り出します。
     今回の場合は、.localDomainMsk(コンピュータ全体) や .networkDomainMask(インターネット上) を使うと実行時エラーになります。.userDomainMask か .allDomainMask を使えば大丈夫です。
     なお、ファイルやディレクトリを表すパスは、以前は URL と String の両方の形式を使っていましたが、今は URL 形式にするのが標準的になっているみたいです。

  14. let fileURL = dirURL.appendingPathComponent("todo.txt")
    前述で作ったファイルパスの最後にデータファイルの名前を追加しています。FileManeger.default.urls で作成できるのは、指定のディレクトリの場所までですので、この作業が必要になります。

  15. do {try result.write(to: fileURL, atomically: true, encoding: .utf8)} catch {fatalError("ファイルが作成できません。")}
    例外処理の中でファイルへの書き込み(保存)を行なっています。result は文字列(String)ですが、String は .write(書き込み) というメソッドを持っています。to 引数にはファイルパスを指定します。atomically 引数に true を指定すると、データを書き込む前に元ファイルのデータのコピーを取ります。encode 引数には通常 .utf8 を指定すればよいでしょう。

  16. func read() {・・・if FileManager.default.fileExists(atPath: fileURL.path) {
    read(読み込み)メソッドです。ファイルパスを作るところまでは save と同じです。FileManager の default.Exists メソッドを使って、ファイルパスにファイルが存在しているか確かめています。atPath 引数のファイルパスの最後には .path が必要です。

  17. guard let fileContents = try? String(contentsOf: fileURL, encoding: .utf8) else { fatalError("ファイルが読み込めません") }
    String型を作るときに、contentsOf 引数にァイルを指定すれば、そのファイルの String が作成できます。

  18. todos = fileContents.components(separatedBy: "\n")
    String 型の .components メソッドを呼び出せば、separatedBy 引数に指定した文字で分割された配列を返します。save メソッドで最後の改行コードを取っていたのは、components で配列を作る場合に最後の改行コードで空文字列の配列要素が追加されてしまうからです。


View.swift

次のコードは 、ターミナルでビルドする場合の専用コードです。一応、記念に残しておきます。というかコード説明は、こちらのほうが分かりやすいと思います。データはあなたのユーザーフォルダに作成されます。
 このコードを試す時は、「あなたのユーザー名」の部分を必ず書き換えてください。そうでないとビルドは成功しますが、実行時にエラーになります。


/************************************
    View.swift
    copyright    :   vivacocoa.jp
    last modified:   Jul. 15, 2022
************************************/

import SwiftUI

struct ContentView: View {
    @State var new = ""
    @State var todos: [String] = []

    var body: some View {
        VStack {
            HStack {
                TextField("New Todo", text: $new)
                Button(action: {
                    if new != "" {
                        todos.insert(new, at: 0)
                        new = ""
                        save()
                    }
                }, label: {Text("Add")})
            }
            List {
                ForEach(todos, id:\.self) { todo in Text(todo) }
                .onDelete(perform: { idx in todos.remove(atOffsets: idx); save() })
                .onMove(perform:{idx, n in todos.move(fromOffsets:idx, toOffset: n); save()})
                //.onDelete(perform: rowRemove)
                //.onMove(perform: rowReplace)
            }
            /*
            .toolbar {
                Button(action: {}, label: {Text("Edit")})
            }
            */
        }
        .padding(10)
        .frame(minWidth: 300, maxWidth: .infinity, minHeight: 200, maxHeight: .infinity)
        .onAppear() { read() }
        .onDisappear(){ NSApplication.shared.terminate(self) }
    }
    /*
    func rowRemove(offsets: IndexSet) {
        todos.remove(atOffsets: offsets)
        save()
    }
    func rowReplace(_ from:IndexSet, _ to:Int) {
        todos.move(fromOffsets: from, toOffset: to)
        save()
    }
    */
    func save() {
        var tmp:String = ""
        for str in todos {
            tmp += str + "\n"
        }
        let result = tmp.dropLast()
        guard let fileURL = URL(string: "file:///Users/あなたのユーザー名/todo.txt") else{
            fatalError("パス作成に失敗しました!")
        }
        do {
            try result.write(to: fileURL, atomically: true, encoding: .utf8)
        } catch {
            //print("Error: \(error)")
            fatalError("データの書き込みに失敗しました!")
        }
    }
    func read() {
        guard let fileURL = URL(string: "file:///Users/あなたのユーザー名/todo.txt") else {
            fatalError("パス作成に失敗しました!")
        }
        if FileManager.default.fileExists(atPath: fileURL.path) {
            guard let fileContents = try? String(contentsOf: fileURL, encoding: .utf8) else {
                fatalError("データの読み込みに失敗しました!")
            }
            todos = fileContents.components(separatedBy: "\n")
        } else {
            guard FileManager.default.createFile(atPath: fileURL.path,
            contents: nil, attributes: nil) else {
                fatalError("ファイルの作成に失敗しました!")
            }
        }
    }
}
	

コード説明

  1. @State var new = ""
    @State 属性をつけて、空の文字列プロパティを宣言しています。

  2. @State var todos: [String] = []
    @State 属性を付けて、空の文字列配列のプロパティを宣言しています。

  3. HStack {
    テキストフィールドとボタンを横に並べるために HStack を使います。

  4. TextField("New Todo", text: $new)
    テキストフィールドを設定しています。第1引数は何も入力されていないときに薄い色で表示される文字列です。プレースホルダーと言います。第2引数で new プロパティとバインドさせています。

  5. Button(action: {if new != "" {todos.insert(new, at: 0); new = ""; save()}
    new が空文字列でなけれれば todos 配列の先頭に new を挿入します。そして new を空文字列に設定し、save() メソッドを呼び出して、todos 配列をファイルへ保存します。

  6. List { ForEach(todos, id:\.self) { todo in Text(todo) }
    todos 配列から一つずつ要素を取り出して、List にしています。

  7. .onDelete(perform: { idx in todos.remove(atOffsets: idx); save() })
    List には onDelete モディファイアが用意されています。onDelete はリスト項目につけることに注意してください。クロージャーが分かりにくかったら、コメントアウトしたモディファイアと rowRemove メソッドを参考にしてください。

  8. .onMove(perform:{idx, n in todos.move(fromOffsets: idx, toOffset: n); save()})
    List には onMove モディファイアが用意されています。onMove はリスト項目につけることに注意してください。クロージャーが分かりにくかったら、コメントアウトしたモディファイアと rowReplace メソッドを参考にしてください。

  9. .onAppear() { read() }
    ウィンドウが現れる時にデータを読み込みます。

  10. func save() {
    save メソッドを定義します。

  11. var tmp:String = ""; for str in todos {tmp += str + "\n"}
    空の String 型の tmp 変数を用意します。そしてそこに todos 配列から一つづつ要素を取り出して tmp 変数に追加します。追加する際に要素の最後に改行文字を追加しています。

  12. let result = tmp.dropLast()
    result 定数に tmp から最後の一文字を削除したテキストを代入しています。最後の一文字は改行コードです。

  13. guard let fileURL = URL(string: "file:///Users/あなたのユーザー名/todo.txt") else {fatalError("パス作成に失敗!")}
    パス作成に失敗した場合を想定して、guard を付けて fileURL 定数を宣言しています。URL()は string: 引数の文字列を URL に変換します。Apple では、ファイルのパスに URL を使うみたいです。

  14. do {try result.write(to: fileURL, atomically: true, encoding: .utf8)} catch {fatalError("書き込みに失敗!")}
    String 型は write メソッドを持っています。to: にパスを設定します。 atomically: を true にすると、書き込みに失敗した場合に備えて、書き込みする前に元のファイルのバックアップを取ります。encoding: にエンコーディングを設定します。

  15. func read() {
    read メソッドを定義します。

  16. if FileManager.default.fileExists(atPath: fileURL.path) {guard let fileContents = try? String(contentsOf: fileURL) else {fatalError("データの読み込みに失敗しました!")}
    ファイルを管理する FileManager を使ってパスにファイルが存在しているか確認しています。ファイルが存在すれば fileContents にファイルの内容を代入します。

  17. todos = fileContents.components(separatedBy: "\n")
    fileContents の内容を "\n" 文字で区切って、それぞれを要素とした配列を todos に代入しています。

  18. guard FileManager.default.createFile(atPath: fileURL.path, contents: nil, attributes: nil) else {fatalError("ファイルの作成に失敗しました!")
    ファイルパスにファイルが存在しなければ、ファイルパスにファイルを作ります。


App.swift

今まで、Main.swift というファイル名にしていたものは、App.swift に変えました。その方が、内容を表していると思いました。Xcode をお使いの場合は、 プロダクト名App(プロダクト名App.swift)に次のコードを記述してください。


/************************************
    App.swift
    copyright    :   vivacocoa.jp
    last modified:   Jul. 15, 2022
************************************/

import SwiftUI

@main
/*
次の FooApp はプロダクト名と一致していなくても大丈夫です。
このまま使えますし、任意の名前に変更しても大丈夫です。
ただし、大文字で始めなければなりません。
*/
struct FooApp: App {
    init() {
        // Tab(タブ)に関するメニューを削除しています
        NSWindow.allowsAutomaticWindowTabbing = false
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .commands(content: {
            // New Window メニューを削除しています。
            CommandGroup(replacing: .newItem) {}
        })
    }
}
	


アプリケーションの名前を変えるために、Info.plist の CFBundleName を書き換えます。ここでは「ToDo」という名前にしました。 Xcode で作業している場合は、ここは無視してください。 機会があれば、また説明します。

アイコンも フリーアイコンSVG、PNG、ICO、ICNS からダウンロードしました。

Info.plist


<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>CFBundleExecutable</key>
	<string>foo</string>
	<key>CFBundleIconFile</key>
	<string>icon-macbook.icns</string>
	<key>CFBundleName</key>
	<string>ToDo</string>
</dict>
</plist>
    


ビルド


swiftc App.swift View.swift -o foo
mv foo Foo.app/Contents/MacOS
    


Xcode 対応コードは、前述の両用コードと同じなので削除しました。

ターミナル専用コードは、前述のターミナル専用コードと同じなので削除しました。

実行

テキストフィールドに新しい ToDo を入力して「Add」ボタンをクリックすると、リストの最初に ToDo が追加されます。

削除したい項目を左にスワイプすると「Delete」ボタンが現れます。 現れた「Delete」ボタンをクリックすると、その項目が削除されます。 削除を中止する場合は、右にスワイプします。

移動したい項目をドラッグ&ドロップすると任意の位置に移動できます。




「保存」と「読込」の機能がつきました。Xcode でビルドした場合は、SandBox 内に データを保存できるようなります。しかしターミナルでビルドした場合は、通常のユーザーディレクト内にデータが保存されます。 App Store へ提出するためには、SandBox に対応しなければなりません。やはり Xcode でビルドして提出するしか方法がないのかも。


編集機能付き ToDo

View.swift

Xcode でビルドする場合は、ContentView(ContentView.swift)に次のコードを記述してください。ターミナルでビルドする場合は、View.swift に記述してください。どちらの場合も プロダク名App.swift(App.swift)に変更はありません。


/************************************
    View.swift
    copyright    :   vivacocoa.jp
    last modified:   Jul. 20, 2022
************************************/

import SwiftUI

struct ContentView: View {
    @State var new = ""
    @State var todos: [String] = []
    @State var isEdit = false
    @State var isAlert = false
    @State var n:Int = -1

    var body: some View {
        VStack {
            HStack {
                TextField("New Todo", text: $new)
                Button(action: {
                    if new != "" {
                        if isEdit {
                            todos[n] = new
                            new = ""
                            isEdit = false
                            
                        } else {
                            todos.insert(new, at: 0)
                            new = ""
                        }
                        save()
                    }
                }, label: {Text(isEdit ? "Change" : "Add")})
            }
            List(todos.indices, id: \.self){ index in
                HStack {
                    Text(todos[index])
                    Spacer()
                    Image(systemName: "trash")
                        .onTapGesture(count: 1, perform: {isAlert = true; n = index})
                }
                .onTapGesture(count: 2, perform: {
                    isEdit = true
                    n = index
                    new = todos[index]
                })
            }
            .alert("Comfirm", isPresented: $isAlert){
                        Button("No"){
                            // No ボタンが押された時の処理
                        }
                        Button("Yes"){
                            todos.remove(at: n)
                            save()
                        }
                    } message: {
                        Text("Are you sure to delete this item?")
                    }
        }
        .padding(10)
        .frame(minWidth: 300, maxWidth: .infinity, minHeight: 200, maxHeight: .infinity)
        .onAppear() { read() }
        .onDisappear(){ NSApplication.shared.terminate(self) }
    }
    func save() {
        var tmp:String = ""
        for str in todos {
            tmp += str + "\n"
        }
        let result = tmp.dropLast()
        guard let dirURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
            .first else { fatalError("フォルダのURLが取得できません。") }
        let fileURL = dirURL.appendingPathComponent("todo.txt")
        do {
            try result.write(to: fileURL, atomically: true, encoding: .utf8)
        } catch {
            //print("Error: \(error)")
            fatalError("ファイルが作成できません。")
        }
    }
    func read() {
        guard let dirURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
            .first else { fatalError("フォルダのURLが取得できません。")
        }
        let fileURL = dirURL.appendingPathComponent("todo.txt")
        if FileManager.default.fileExists(atPath: fileURL.path) {
            guard let fileContents = try? String(contentsOf: fileURL, encoding: .utf8)
            else { fatalError("ファイルが読み込めません") }
            todos = fileContents.components(separatedBy: "\n")
        }
    }
}
    


編集したい文字列をダブルクリックするとテキストフィールドにその文字列が表示され、ボタンが「Add」から「Change」に変わります。
テキストフィールドの字列を編集し、「Change」ボタンをクリックします。
リストの文字列が編集どおりに更新され、ボタンが「Add」に戻ります。
文字列の右横の「ゴミ箱」マークをクリックすると、本当に削除するかどうかの確認ダイアログが現れます。
「No」をクリックすると削除は中止され、「Yes」をクリックすると、その項目が削除されます。「No」のほうが Enter キーに反応するデフォルトボタンになっています。




編集機能をつける替わりに、移動機能がなくなりました。


以前のバージョンはリスト項目の移動と削除ができました。これらの機能は、ForEach の id 形式でリストを作った場合にだけ使えるリストの .onDelete と .onMove というモディファイアでした。今回は編集機能をつけるためにデータ配列のインデックスが必要だったので、リストの indices という呼び出し方法でリストを作りました。その結果、.onDelete と .onMove が使えなくなりました。

代わりに通常の配列の削除を方法を使って、データの削除は実現できましたが、データの移動はできません。

なお一応 CRUD(クラッド)という言葉があるらしくって、アプリケーションが備えているべき 4 つの機能をデータの生成(Create)、データの読み取り(Read)、データの更新・変更(Update)、データの削除(Delete)の頭文字を並べたものだそうです。この考えに一応従って、データの移動よりもデータの更新・変更を優先しました。




5975 visits
Posted: Jul. 07, 2022
Update: Jul. 20, 2022

ホーム   目次