【お知らせ】
SwiftUIで作った macOS Todo アプリ
ToDone
を100ダウンロードまで無料にしました。マニュアルページは、ToDone サポートページ です。
【本文】
この章では、リスト(List)という View を使って、ToDo を作ります。
このコーナーでは、任意のテキストエディタでコードを記述し、 ターミナルを使ってビルドする方法で作業を進めています。Xcode で作業を進める場合は、Xcodeで作業する場合 をご一読ください。
なお、ターミナルで作業する場合も、Swift コンパイラや SwiftUI フレームワークなどを Mac にインストールするために Xcode をインストールして、一度起動させなければなりません。インストールが終われば Xcode は終了しても大丈夫です。
また、作成したアプリケーションを App Storeに提出するためのファイルにするには、Xcode を使わなければならなかったかもしれません。どこかで Xcode を使わずに作る方法を見たような気もするのですが...
私の開発環境は次のとおりです。
更新履歴
2022/07/13「新規」機能を追加しました。
2022/07/14「保存」と「読込」の機能を付けました。
2022/07/15「「Xcode ターミナル両用コード」を追加しました。
2022/07/15「ターミナル専用コード」を追加しました。
2022/07/16 コード説明をつけました。
2022/07/18 「編集機能付き ToDO」を追加しました。
2022/07/20 「編集機能付き 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
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")
}
}
}
次のコードは
、ターミナルでビルドする場合の専用コードです。一応、記念に残しておきます。というかコード説明は、こちらのほうが分かりやすいと思います。データはあなたのユーザーフォルダに作成されます。
このコードを試す時は、「あなたのユーザー名」の部分を必ず書き換えてください。そうでないとビルドは成功しますが、実行時にエラーになります。
/************************************
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("ファイルの作成に失敗しました!")
}
}
}
}
今まで、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 からダウンロードしました。
<?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
テキストフィールドに新しい ToDo を入力して「Add」ボタンをクリックすると、リストの最初に ToDo が追加されます。
削除したい項目を左にスワイプすると「Delete」ボタンが現れます。 現れた「Delete」ボタンをクリックすると、その項目が削除されます。 削除を中止する場合は、右にスワイプします。
移動したい項目をドラッグ&ドロップすると任意の位置に移動できます。
「保存」と「読込」の機能がつきました。Xcode でビルドした場合は、SandBox 内に データを保存できるようなります。しかしターミナルでビルドした場合は、通常のユーザーディレクト内にデータが保存されます。 App Store へ提出するためには、SandBox に対応しなければなりません。やはり Xcode でビルドして提出するしか方法がないのかも。
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")
}
}
}
編集機能をつける替わりに、移動機能がなくなりました。
以前のバージョンはリスト項目の移動と削除ができました。これらの機能は、ForEach の id 形式でリストを作った場合にだけ使えるリストの .onDelete と .onMove というモディファイアでした。今回は編集機能をつけるためにデータ配列のインデックスが必要だったので、リストの indices という呼び出し方法でリストを作りました。その結果、.onDelete と .onMove が使えなくなりました。
代わりに通常の配列の削除を方法を使って、データの削除は実現できましたが、データの移動はできません。
なお一応 CRUD(クラッド)という言葉があるらしくって、アプリケーションが備えているべき 4 つの機能をデータの生成(Create)、データの読み取り(Read)、データの更新・変更(Update)、データの削除(Delete)の頭文字を並べたものだそうです。この考えに一応従って、データの移動よりもデータの更新・変更を優先しました。