LINEヤフー Tech Blog

LINEヤフー株式会社のサービスを支える、技術・開発文化を発信しています。

【Code Review Challenge】解説2問目:SwiftUIとSwiftData #try! Swift Tokyo 2024

こんにちは。天気・災害サービスiOSアプリエンジニアの冨田です。

先日開催されたtry! Swift Tokyo 2024のLINEヤフー企業ブースでは、Code Review Challengeを行いました。Code Review Challengeとは、Bad CodeをGood Codeにするための公開コードレビューです。参加者に技術への関心を持ってもらうこと、外部の方々からのレビューにより社員の学びにつなげることを目的に、過去のイベントで行っていた取り組みを今回のイベントでも実施しました。

本記事では、今回出題したCode Review Challengeの2問目の解説をします。

出題コード

import SwiftUI
import SwiftData

struct ContentView: View {
    // Initialized ModelContainer will be passed from the parent view.
    @Environment(\.modelContext) private var modelContext
    // Todo class is decleared with @Model annotation on another file.
    @Query private var todos: [Todo]
    @State private var showingAddItemAlert = false
    @State private var newTodo = ""

    var body: some View {
        NavigationView {
            List {
                ForEach(Array(todos.filter { !$0.isDone }.enumerated()), id: \.offset) { index, todo in
                    Button(todo.title) {
                        todos[index].isDone.toggle()
                    }
                }
                .onDelete(perform: deleteItems)
            }
            .toolbar {
                Button(action: {
                    showingAddItemAlert = true
                }, label: {
                    Image(systemName: "plus")
                })
            }
            .overlay {
                if todos.count == 0 {
                    ContentUnavailableView(
                        "All Done",
                        systemImage: "checkmark",
                        description: Text("You've completed all your tasks. Congratulations!")
                    )
                }
            }
            .alert("Add New Todo", isPresented: $showingAddItemAlert) {
                TextField("New Todo", text: $newTodo)
                Button("Add", action: addItem)
                    .disabled(newTodo.isEmpty)
                Button("Cancel", role: .cancel, action: {})
            }
        }
    }

    private func addItem() {
        let newItem = Todo(title: newTodo)
        modelContext.insert(newItem)
        newTodo = ""
    }

    private func deleteItems(offsets: IndexSet) {
        for index in offsets {
            modelContext.delete(todos[index])
        }
    }
}

こちらはSwiftDataを用いたTODOアプリのコードです。こちらには、10個以上の改善点が含まれているのですが今回は主に以下の2点について紹介できればと思います。

  1. SwiftDataから取得するデータの扱いについて
  2. アラートを用いたTODOの追加について

SwiftDataから取得するデータの扱いについて

上記のコードではListを表示する際にtodosをフィルターしています。そのため、表示上は問題ありませんが、削除や編集(今回の場合はタスクのisDoneをtrueにする)をする際に、ズレたインデックスの要素を操作してしまいます。フィルターしたTODOのIndexを検索するようにすればこちらの問題は解決しますが、検索を行うことにより処理時間が伸びてしまうので最適な解決法ではありません。こちらの問題は、SwiftDataが提供するAPIを使用することで解決できます。実際に修正を加えたコードが以下です。

// 前略
    @Query(filter: #Predicate<Todo> { !$0.isDone }) private var todos: [Todo]
    @State private var showingAddItemAlert = false

    var body: some View {
        NavigationView {
            List {
                ForEach(todos) { todo in
                    Button(todo.title) {
                        todo.isDone.toggle()
                    }
                }
                .onDelete(perform: deleteItems)
            }
// 後略

Swift Dataで保存している内容を取得する際は@Queryを用いてデータを取得するのですが、こちらにはfilterやsortなどのオプションを追加できます。こちらを用いることで、取得する際に要素をフィルターできます。また、要素の変更をする際もフィルターされた後の配列のインデックスをそのまま使用できます。SwiftDataのfilterやsortを用いることで、要素のフィルターや並び替えが容易にできるだけでなく、より直感的に配列を操作できるようになります。

アラートを用いたTODOの追加について

こちらの章では、TODOの追加部分の実装について解説をします。

.alert("Add New Todo", isPresented: $showingAddItemAlert) {
    TextField("New Todo", text: $newTodo)
    Button("Add", action: addItem)
        .disabled(newTodo.isEmpty)
    Button("Cancel", role: .cancel, action: {})
}

上記のような実装の場合は、ボタンが押されアラートが消える時のみユーザーが入力した値がnewTodoに反映されます。そのため現状では、addItemの呼び出しと同時もしくは後に値が反映されるため、動作が不安定になってしまいます。こちらを回避するには、UIViewControllerRepresentableを用いてアラートを表示させるか、シートを用いて入力フォームを作成する必要があります。

そのほかの修正点

Toolbar

今回の実装ではtoolbarの要素としてButtonを使用していますが、下記のようにToolbarItemを用いることでデバイスによる表示の違いにもより柔軟に対応できます。

ToolbarItem {
    Button("Add Todo", systemImage: "plus") {
        showingAddItemAlert = true
    }
}

また下記のようにToolBarItem内のボタンにaccessibilityLabelを設定することでVoiceOverに対応させることができ、目が不自由な方などにもより快適にアプリを使っていただけるようになります。

ToolbarItem {
    Button("Add Todo", systemImage: "plus") {
        showingAddItemAlert = true
    }
    .accessibilityElement()
    .accessibilityLabel("Add Todo")
}

UX / DX(Developer Experience)向上

  • 要素の追加や削除を行うコードをwithAnimationで括ることで、実行時にアニメーションを追加する。
  • 可読性向上かつよりモダンな実装にするために、todos.count == 0todos.isEmptyに変更する。
  • showingAddItemAlertisAddItemAlertShowingなど、変数が明示的になっていない変数があるため変更する。

想定解答

上記の問題点を解決した想定解答は以下です。こちらには記載できていないUIKitAlertでは、UIViewControllerRepresentableを持ちいてUIKitのアラートを呼び出しています。

import SwiftUI
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query(filter: #Predicate<Todo> { !$0.isDone }) private var todos: [Todo]
    @State private var showingAddItemAlert = false

    var body: some View {
        NavigationView {
            List {
                ForEach(todos) { todo in
                    Button(todo.title) {
                        todo.isDone.toggle()
                    }
                }
                .onDelete(perform: deleteItems)
            }
            .toolbar {
                ToolbarItem {
                    Button("Add Todo", systemImage: "plus") {
                        showingAddItemAlert = true
                    }
                    .accessibilityElement()
                    .accessibilityLabel("Add Todo")
                }
            }
            .overlay {
                if todos.isEmpty {
                    ContentUnavailableView(
                        "All Done",
                        systemImage: "checkmark",
                        description: Text("You've completed all your tasks. Congratulations!")
                    )
                }
            }
            .background(UIKitAlert(isPresented: $showingAddItemAlert, title: "Add New Todo") { text in
                addItem(text)
            })
        }
    }

    private func addItem(_ text: String) {
        withAnimation {
            let newItem = Todo(title: text)
            modelContext.insert(newItem)
        }
    }

    private func deleteItems(offsets: IndexSet) {
        withAnimation {
            for index in offsets {
                modelContext.delete(todos[index])
            }
        }
    }
}

最後に

今回、SwiftDataを題材にした問題を用意しましたが、参加者の中にはすでに類似の問題に取り組んだ経験がある方々もおり、国際的に開催される「try! Swift」レベルの高さや参加者の多様さをあらためて実感できました。また作問時点では想定していなかったご指摘事項などもたくさんいただき、問題を出す側としてもとても学びのある企画だったと思います。今後も今回の学びをサービス開発で活かすだけでなく、各種イベントで社外の皆様にも発信していければと思います!