💽

SwiftDataで自己参照モデルの作成と表示

2023/09/28に公開

SwiftDataで一対多の自己参照モデルを作成し、一覧表示、追加、削除できるようにします。

完成イメージ↓

https://github.com/tsonobe1/SwiftDataPlayground/issues/4

モデルを作成

深くネストできるを作成するイメージで作成します。

import SwiftUI
import SwiftData

@Model
final class Box {
    @Attribute(.unique) var id: UUID
    var title: String
    var detail: String
    var createdData: Date = Date()

    @Relationship(deleteRule:.cascade)
    var children: [Box] = []
    var parent: Box?
    
    init(title: String, detail: String, parent: Box?) {
        self.id = UUID()
        self.title = title
        self.detail = detail
        self.createdData = Date()
        self.parent = parent
    }   
}

modelContainerの設定

WindowGroupに作成したModelを設定しておきます

import SwiftUI
import SwiftData

@main
struct DeepNestedSwiftDataApp: App {
    var body: some Scene {
        WindowGroup {
            BoxList()
        }
        .modelContainer(for: Box.self)
    }
}

最上位のBoxを一覧表示し、追加・削除を行うView

import SwiftUI
import SwiftData

struct BoxList: View {
    @Environment(\.modelContext) private var context
    // Parentが存在しないBoxのみをfetch
    @Query(filter: #Predicate<Box> { $0.parent == nil },
           sort: [SortDescriptor(\.createdData)] )
    var parentLessBoxes: [Box]
    
    // データ追加用のサンプル
    let sampleTitle = "Lorem ipsum..."
    let sampleDetail = "Sed ut..."
        
    var body: some View {
        NavigationStack{
            VStack(alignment: .leading){
	    // Boxを一覧表示
                ScrollView(showsIndicators: false) {
                    ForEach(parentLessBoxes){ box in
                        NavigationLink {
			// DetailViewへの遷移
                            BoxDetail(box: box)
                        } label: {
                            VStack(alignment:.leading) {
                                Text(box.title)
                                    .font(.headline)
                                    .multilineTextAlignment(.leading)
                                    .lineLimit(2)
                            // 略
                }
                
		// 親がないBoxを追加する
                Button("add"){
                    let new = Box(title: sampleTitle, detail: sampleDetail, parent: nil)
                    context.insert(new)
                }
		// Boxのすべてのインスタンスを削除
                Button("All Delete"){
                    try?context.delete(model: Box.self, includeSubclasses: false)
                }
		// 略

選択されたBoxの詳細View

import SwiftUI
import SwiftData

struct BoxDetail: View {
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
    @Environment(\.modelContext) private var context
    let box: Box
    @State private var isDeleteSheetPresented = false
    
    var body: some View {
        ScrollView{
            VStack(alignment: .leading){
	    // BoxのChildren一覧を表示するView
                ChildBoxList(box: box)
            }
        }
        .padding(5)
        .navigationBarTitleDisplayMode(.inline)
        .toolbar{
            ToolbarItem(placement: .topBarTrailing) {
                Button {
                    isDeleteSheetPresented.toggle()
                } label: {
                    Image(systemName: "trash")
                }
                
            }
        }
        // Boxの削除
        .alert("\(box.title)を削除しますか?", isPresented: $isDeleteSheetPresented) {
            //            Button("Cancel") {isDeleteSheetPresented.toggle()}
            Button("Delete", role: .destructive) {
                context.delete(box)
                self.presentationMode.wrappedValue.dismiss()
            }
        }
    }
}

選択されたBoxの子Boxをリスト表示

box.childrenという形で、boxに紐づいている子要素をForEachで回して表示した場合、その子要素に変更があってもViewがリフレッシュされませんでした。
子Viewに子要素の一覧を渡すと、値の変更が追跡されなくなってしまうようです。
参考:SwiftUI not updating View after changing attribute value of a relationship entity

そのため、子Viewのinitializer内で改めてQueryをして、子boxの一覧を取得しています。

struct ChildBoxList: View {
    @Environment(\.modelContext) private var context
    let box: Box
    
    @Query(sort: \Box.createdData, order: .forward)
    var children: [Box]
    
    // 子boxの一覧を改めてfetch
    init(box: Box) {
        self.box = box
        let boxID = box.id
        let filter = #Predicate<Box> { box in
            box.parent?.id == boxID
        }
        let query = Query(filter: filter, sort: \.createdData)
        _children = query
    }
    
    var body: some View {
        if children.isEmpty {
            ContentUnavailableView {
                Label("No child box", systemImage: "")
            }
        }else{
            ForEach(children){ child in
	    // 子Boxと、更にその子、孫を表示するためのView
                ChildRow(child: child, isLast: children.last?.id == child.id)
            }
        }
        
	// parentに親のboxを入れて、context.insertする
        Button("add"){
            let newBox = Box(title: sampleTitle, detail: sampleDetail, parent: box)
            context.insert(newBox)
        }
    }
    
    let sampleTitle = "Lorem ipsum ..."
    let sampleDetail = "Sed ut ..."
}

更にその子Boxを再帰的に呼び出す

struct ChildRow: View {
    @Environment(\.colorScheme) var colorScheme
    @State private var isExpanded: Bool = false

    let child: Box
    @Query(sort: \Box.createdData, order: .forward)
    var grandChildren: [Box]
    let isLast: Bool
    
    // 孫boxをfetch
    init(child: Box, isLast: Bool) {
        self.child = child
        let childID = child.id
        let filter = #Predicate<Box> { grandChild in
            grandChild.parent?.id == childID
        }
        let query = Query(filter: filter, sort: \.createdData)
        _grandChildren = query
        
        self.isLast = isLast
    }
    
    var body: some View {
        VStack(alignment: .leading,  spacing: 0){
            HStack{
                NavigationLink {
                    BoxDetail(box: child)
                } label: {
                    VStack(alignment: .leading){
                        Text(child.title)
                            .font(.footnote)
                            .lineLimit(2)
                            .multilineTextAlignment(.leading)
                    }
                    .foregroundStyle(colorScheme == .dark ? .white : .black)
                    .padding(5)
                }
                
                if !grandChildren.isEmpty {
                    Image(systemName: "chevron.right")
                        .rotationEffect(.degrees(isExpanded ? 90 : 0))
                        .font(.system(size: 15))
                        .onTapGesture {
                            withAnimation {
                                isExpanded.toggle()
                            }
                        }
                        .padding(.trailing)
                }
                
            }
           // 略
	    
	    // 再帰的に子Boxを呼ぶ
            VStack(spacing: 0){
                if isExpanded {
                    ForEach(grandChildren) { grandChild in
                        ChildRow(child: grandChild, isLast: grandChildren.last?.id == grandChild.id)
                            .padding(.leading)
                    }
                }
            }
        }
    }
}

Discussion