💽
SwiftDataで自己参照モデルの作成と表示
SwiftDataで一対多の自己参照モデルを作成し、一覧表示、追加、削除できるようにします。
完成イメージ↓
モデルを作成
深くネストできる箱
を作成するイメージで作成します。
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