1
1

SwiftUIでiOSアプリを作成してみた~Railsしかわからない人がSwiftUIでTodoアプリを作れるようになるまで~

Last updated at Posted at 2024-03-17

はじめに

筆者はwebアプリケーションフレームワーク(Rails)しか触ったことがなく、「俺はこのままでいいのか?」と思って今回iOSアプリに挑戦してみました。今までいろいろwebアプリを個人的に作ってきましたが、友達に見せたい時、いちいち「このURLにアクセスしてみて」と言わないといけないし、真っ先に「AppStoreにないの?」と言われてしまう。「このアプリ使ってみて!」とAppStore見せられるほうがカッコいい。
今後デバイスはなんであれApple製にインストールできるアプリの需要は確実にあるはずだから、学んでおいて損はないだろう!と思い今回挑戦しました。

最初に思ったこと

  • iOSアプリのライフサイクル(webページだったらリロードすれば更新されるけど...)
  • データベースってどうなるんだ?(MySQLとかリレーショナルデータベース使えるの?)
  • デザインどうするの?
  • 静的型付け言語か...初めてだな(RubyとかJSしかやってこなかったしC#は理解する前にやめた)
  • VScode使えるのかな?(Xcodeじゃないとだめ?)
  • デバッグってどうやるんだ?(ターミナルから?Xcodeから?)

と、このレベルです。

わかったこと

1. VScodeの出番はない

VScodeに色々いれたプラグインが使えないのか...

以下しばらく文法の基礎をメモしただけです
わかる人は読み飛ばしてください。

2. 変数と定数

var:変数
let:定数(JSだとどっちも変数なのに...)

3. 型について

特に明示的にしない場合、よしなに型を定義してくれる

スクリーンショット 2024-01-28 14.50.18.png

じゃあこうやったらどうなるんだろう

スクリーンショット 2024-01-28 14.52.01.png

ちゃんとエラーが出るんですね。

型を変換したいときはどうするんだろう

スクリーンショット 2024-01-28 15.02.41.png
Stirng()で囲えばいいんですね。ふむふむ、じゃあ逆はどうだろう
スクリーンショット 2024-01-28 15.02.13.png
だめなんですね。

Int(bar)がnil(変換できなかった場合)を返す可能性があるためです。Swiftでは、Int(bar)の戻り値がオプショナル型であるため、nilの可能性を考慮する必要があります。

オプショナル型とはなんだろう...。

4.オプショナル型とは

値が存在しない可能性があることを表現するための型なんですね。
Swiftでは、通常の型(整数、文字列など)が必ず値を持つ一方で、オプショナル型は値が存在しないことを示すことができるということか。

そこで

強制的アンラップ

!をつけて呼び出すことで強制的に値を取り出すことができる
なるほど。ということはさっきのコードを以下のように変えれば...

できました。が、nil落ちするので使い所は要注意ですね。

オプショナルバインディング

sample1.swift
var foo = 1
var bar: Int? = 1

if let unwrappedBar = bar {
    print(foo + unwrappedBar)
}
else {
    print("bar is nil")
}

このようにすれば

sample2.swift
var foo = 1
var bar: Int? = nil //変更箇所

if let unwrappedBar = bar {
    print(foo + unwrappedBar)
}
else {
    print("bar is nil")
}

nilの時でも安全に処理をわけることができる。

5. if, switch, 関数, クロージャ, クラス, 継承, インスタンスについて

想像通りだったので割愛。
クロージャというのは初めて聞いたが無名関数みたいなものだなと思った

6. 構造体とプロトコル

クラスを作ってインスタンスを生成する時、以下のようにするのは思った通りというところですが

sample3.swift
struct Monster {
    var name: String
    var dropItem: String
    
    init(name: String, dropItem: String) {
        self.name = name
        self.dropItem = dropItem
    }
    
    func attack() {
        print("\(name)の攻撃!▼")
    }
}

// 呼び出し例
var slime = Monster(name: "スライム", dropItem: "やくそう")
slime.attack()

プロトコルというものを設定できるんですね。
「約束ごと」という名前の通り、構造体の内容を保証するものなんですね。
定義し忘れちゃった!とかがなくなっていいですね。
またプロパティに対してgetsetと定義するだけでそのプロパティが参照しかできるか、変更が可能か、またその両方かを定義できるんですね。
ゲッターメソッド、セッターメソッドをいちいち定義しなくていいイメージ。
(Rubyでいうattr_xxxみたいなものですね。)

sample4.swift
protocol MonsterProtocol {
    var name: String { get }
    func attack()
}

struct Monster: MonsterProtocol { //プロトコルの指定
//以下同文

7. as(型キャスト)

その前に型キャストとは

インスタンスの方をほかの型として扱うこと。
例えばAny型のインスタンスをString型で扱うなど。

アップキャスト

サブクラスのインスタンスをスーパークラスの型に変換する。
asを利用する

ダウンキャスト

スーパークラスのインスタンスをサブクラスの型に変換する。
as?:安全な型キャストを行える。キャストが行えないときはnilが返される
as!:強制的な型キャスト。失敗するとエラーが発生して意しまうため注意が必要

なるほど、Swiftで!は強制的〇〇なのか🤔

sample3.swift
var any: Any = 1024 
var str: String = "String"
var int: Int = 1024

var anyStr = str as Any
print(anyStr)

8. @State

値を画面から変更する際に利用する(ボタンをタップしたときなど)

9. @ObservedObject

@Stateと違いインスタンスをの値を画面から変更する際に利用する

Monster.swift
import SwiftUI

class Monster: ObservableObject { // ObservableObjectというプロトコルを指定
    @Published var name: String // 画面から変更したい値には@Publishedをつける
    @Published var hp: Int  // 画面から変更したい値には@Publishedをつける
    
    init(name: String, hp: Int) {
        self.name = name
        self.hp = hp
    }
}
contentView.swift
import SwiftUI

struct ContentView: View {
    @ObservedObject var monster = Monster(name: "スライム", hp: 10)
    var body: some View {
        VStack {
            List {
                Text(monster.name)
                Button(action: {monster.name = "ドラキー"}) {
                    Text("名前をかえる")
                }
                Text("HP:\(monster.hp)")
                Button(action: {monster.hp = 15}) {
                    Text("HPをかえる")
                }
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

スクリーンショット 2024-02-17 17.00.57.png
ボタンを押すと
スクリーンショット 2024-02-17 17.01.02.png
値が変化する

10. @EnvironmentObject

ではなぜ@ObservedObject以外にもう一個@EnvironmentObjectを理解しないといけないのか。
わかりやすい例を用意しました。

Monster.swift
import SwiftUI

class Monster: ObservableObject {
    @Published var name = "スライム"
    @Published var hp = 10
}
ContentView.swift
import SwiftUI

struct ContentView: View {
    @ObservedObject var monster = Monster()
    var body: some View {
        VStack {
            Text("ContentView")
            List {
                Text(monster.name)
                Button(action: {monster.name = "ドラキー"}) {
                    Text("名前をかえる")
                }
                Text("HP:\(monster.hp)")
                Button(action: {monster.hp = 15}) {
                    Text("HPをかえる")
                }
            }
            anotherContentView()
        }
        
    }
}
// もう一つ別のcontentViewを用意する
struct anotherContentView: View {
    @ObservedObject var monster = Monster()
    var body: some View {
        Text("anotherContentView")
        VStack {
            List {
                Text(monster.name)
                Button(action: {monster.name = "ドラキー"}) {
                    Text("名前をかえる")
                }
                Text("HP:\(monster.hp)")
                Button(action: {monster.hp = 15}) {
                    Text("HPをかえる")
                }
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Viewを二つ用意してみました。この状態で名前を変えることができるか試してみます。
スクリーンショット 2024-02-17 18.15.55.png
ボタンを押しても片方しか変わりません。

スクリーンショット 2024-02-17 18.16.05.png
これはViewごとに独立したインスタンスをもっているためです。
「そうじゃなくて両方値がわかって欲しい!(共通のインスタンスであって欲しい)」という時に@EnvironmentObjectの出番です

ContentView.swift
import SwiftUI

struct ContentView: View {
    @EnvironmentObject var monster: Monster //変更箇所
    var body: some View {
        VStack {
            Text("ContentView")
            List {
                Text(monster.name)
                Button(action: {monster.name = "ドラキー"}) {
                    Text("名前をかえる")
                }
                Text("HP:\(monster.hp)")
                Button(action: {monster.hp = 15}) {
                    Text("HPをかえる")
                }
            }
            anotherContentView()
        }
        
    }
}

struct anotherContentView: View {
    @EnvironmentObject var monster: Monster //変更箇所
    var body: some View {
        Text("anotherContentView")
        VStack {
            List {
                Text(monster.name)
                Button(action: {monster.name = "ドラキー"}) {
                    Text("名前をかえる")
                }
                Text("HP:\(monster.hp)")
                Button(action: {monster.hp = 15}) {
                    Text("HPをかえる")
                }
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
            // 以下を追加
            .environmentObject(Monster())
    }
}

@EnvironmentObjectを利用することで共通のインスタンスとして扱えるようになりました。

11. @Bindning

@Bindingとは、別のViewでそのViewの@State変数を変更するために使う。
インスタンスではないけれど別のViewの@Stateの値を変えたい時に使うんですね。

実際にTODOアプリを作ってみよう

完成図はこちら。
これを目指して作ってみましょう。
スクリーンショット 2024-03-17 21.01.27.png

SwiftUIの基本的な文法であるHStackTextについては解説していません。

1. とりあえず、見た目の部分を作成

表示画面であるのContentViewに加え、今回はTodo.swiftという構造体を用意します。

Todo.swift
import SwiftUI

struct Todo: View {
    var todo: String 
    var isChecked: Bool
    
    var body: some View {
        HStack {
            Image(systemName: isChecked ? "checkmark.square.fill" : "square")
                            .resizable()
                            .frame(width: 18, height: 18)
                            // チェックされている場合は青色、そうでない場合はグレー色にする
                            .foregroundColor(isChecked ? .blue : .gray)
            Text(todo)
        }
    }
}
ContentView.swift
import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationView {
            List {
                // とりあえずハードコーディング
                Todo(todo: "朝食", isChecked: true)
                Todo(todo: "筋トレ", isChecked: false)
                Todo(todo: "洗濯", isChecked: true)
            
            }
            // タイトル部分(NavigationViewの中で使えるモディファイア)
            .navigationBarTitle(Text("TODO"))
        }
    }
}

#Preview {
    ContentView()
}

動的に値を変更できるように上記を修正していきます。

2. Todoという構造体を用意する

Todo.swift
import SwiftUI

struct Todo: Identifiable, Equatable {
    let id = UUID() // 一意となる識別子
    var title: String
    var isChecked: Bool
    
    init(title: String, isChecked: Bool) {
        self.title = title
        self.isChecked = isChecked
    }
}

//本当はTodoData.swiftファイルを作成して別に書き出した方が良いかもしれないが、今回は記述量が少ないので同一ファイルに入れる
class TodoData: ObservableObject {
    @Published var todos: [Todo] = [] // 画面に表示するTodoリストの配列
    @Published var isEditing: Bool = false // 追加中かどうかを判定する
}

Todoインスタンスを作成するための雛形を用意します。

Todoについて

title, isCheckedは画面から参照するためinit内に定義しています。

TodoDataについて

画面にはTodoの一覧を表示するのでTodoインスタンスの配列をここに定義しておきます。
isEditingは新たにTodoを追加している状態かどうかを判定するために使います。

3. ContentView

ContentView.swift
import SwiftUI

struct ContentView: View {
    // TodoData環境オブジェクトを利用
    @EnvironmentObject var todoData: TodoData
    
    var body: some View {
        NavigationView {
            List {
                // TodoData内の全てのtodoをリスト表示
                ForEach(todoData.todos) { todo in
                    // リスト内の各ToDoに対するトグル可能なボタン
                    Button(action: {
                        // 選択されたToDoのインデックスを取得
                        // todoData.todos内に対象のToDoが存在しない場合、firstIndex(of:)メソッドはnilを返すため、guard文を使用して取得したインデックスがnilでないことを確認し、nilであれば早期に処理を終了する。
                        guard let index = self.todoData.todos.firstIndex(of: todo) else {
                            return
                        }
                        
                        // 選択されたToDoのisCheckedプロパティをトグル
                        // もしToDoがチェックされている状態(isCheckedがtrue)であれば、この行の実行によってisCheckedがfalseになり、逆にチェックされていない状態(isCheckedがfalse)であれば、isCheckedがtrueになる。
                        self.todoData.todos[index].isChecked.toggle()
                    }) {
                        // TodoViewにToDoのタイトルとisChecked状態を渡す
                        TodoView(title: todo.title, isChecked: todo.isChecked)
                    }
                }
                
                // 編集モードの場合はToDoの作成フォームを表示
                if self.todoData.isEditing {
                    CreateTodo()
                }
                // 編集モードでない場合は「追加する」ボタンを表示
                else {
                    Button(action: {
                        // 編集モードに切り替え
                        self.todoData.isEditing = true
                    })
                    {
                        Text("追加する")
                            .fontWeight(.bold)
                    }
                }
            }
            // NavigationViewのタイトルを設定
            .navigationBarTitle(Text("TODO"))
        }
    }
}

では次にTodoリストの見た目をどのように整えているか、TodoViewについて説明していきます。

TodoView.swift
import SwiftUI

struct TodoView: View {
    var title: String // ToDoのタイトル
    var isChecked: Bool // ToDoがチェックされているかどうかを示すフラグ
    
    var body: some View {
        HStack {
            // チェックされている場合はチェックマークを表示し、そうでない場合はチェックボックスを表示
            Image(systemName: isChecked ? "checkmark.square.fill" : "square")
                .resizable()
                .frame(width: 18, height: 18)
                // チェックされている場合は青色、そうでない場合はグレー色にする
                .foregroundColor(isChecked ? .blue : .gray)
            
            // ToDoのタイトルを表示
            Text(title)
                .foregroundColor(Color.black)
        }
    }
}

では次にどのようにTODOリストへの新規追加を実現しているか、解説していきます。

CreateTodo.swift
import SwiftUI

struct CreateTodo: View {
   // 入力されたToDoのタイトルを保持する状態変数
   @State var todoTitle = ""
   // TodoData環境オブジェクトを利用
   @EnvironmentObject var todoData: TodoData
   
   var body: some View {
       // ユーザーが入力するテキストフィールド
       TextField("やることを入力してください", text: $todoTitle)
           .onSubmit {
               // ユーザーがEnterを押したときにToDoを作成
               self.createTodo()
               // 編集モードを終了
               self.todoData.isEditing = false
           }
   }
   
   // 新しいToDoを作成するメソッド
   func createTodo() {
       // 入力されたタイトルを使用して新しいToDoを作成
       let newTodo = Todo(title: self.todoTitle, isChecked: false)
       // ToDoリストの先頭に新しいToDoを挿入
       self.todoData.todos.insert(newTodo, at: 0)
       // 入力フィールドをクリア
       self.todoTitle = ""
       // 編集モードを終了
       self.todoData.isEditing = false
   }
}

@EnvironmentObjectは、アプリケーション全体で共有されるデータを示すために使用されます。このプロパティラッパーを使用すると、アプリケーション内の任意のビューで同じオブジェクトを共有できます。
今回の場合、TodoDataはToDoアプリ全体で共有されるデータであり、複数のビューで利用されるため、@EnvironmentObjectを使用して、TodoDataをアプリケーション全体で共有可能な状態にします。一方で、@ObservedObjectは特定のビューが特定のオブジェクトを監視する場合に使われるため、この場合は適切ではないわけですね。

つまり、@EnvironmentObjectを使用することで、TodoDataをアプリ全体で共有可能にし、必要なビューでそれを使用できるようにしています。

おわりに(感想)

@State, @Binding, @ObservedObjectなどの@マークを使う変数が慣れないですね...。有効な範囲がいまいち掴みづらいです。
今回はTodoアプリで非常に簡易的なものですが、SwiftUIでつくられた大規模なアプリではファイルやクラス、構造体をどのような単位で分けて書かれているのか非常に興味があるのでみてみたいです。

また本記事において、おかしな内容が含まれていましたら忌憚なくご指摘くださいm(_ _)m
ありがとうございました。

参考文献

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1