Элементы в List без дочерних отображаются при использовании SwiftData со стрелкой

Пытаюсь сделать List c Tree структурой. Когда загружаю данные из SwiftData, все отображается корректно, но после первого сохранения элементы БЕЗ дочерних отображаются со стрелкой. Обнаружил, что items: [Category]! до сохранения - nil, после сохранения - пустой массив Перерыл весь интернет-решения не нашел. Прошу о помощи

import SwiftData
import SwiftUI

@Model
class Category {
    @Attribute(.unique) var name: String
    @Relationship(deleteRule: .nullify, inverse: \Category.parent)
    var items: [Category]?
    var parent: Category?

    init(name: String, parent: Category? = nil) {
        self.name = name
        self.parent = parent
    }
    }

struct ContentView: View {
    @Environment(\.modelContext) var context
    @Query private var categories: [Category]

    var body: some View {
        
        VStack {

        List(categories.filter { $0.parent == nil }, children: \.items) { category in
                    Text(category.name)
               }
            
            HStack {
                Button("Load data") {
                    let one = Category(name: "Parent one")
                    let two = Category(name: "Parent two")
                    let three = Category(name: "Parent three")
                    
                    _ = Category(name: "Child one", parent: one)
                    _ = Category(name: "Child two", parent: one)
                    _ = Category(name: "Child three", parent: two)
                    _ = Category(name: "Child four", parent: two)
                    _ = Category(name: "Child five", parent: three)
                    _ = Category(name: "Child six", parent: three)
                    
                    context.insert(one)
                    context.insert(two)
                    context.insert(three)
    }
                Spacer()

                Button("Add item") {
    }
                
    }.padding(.horizontal)
    }
    }
    }





Ответы (1 шт):

Автор решения: Никита

Проблема, с которой ты столкнулся, связана с тем, как SwiftData обрабатывает отношения и как SwiftUI List отображает данные.

Почему появляется стрелка:

SwiftData и инициализация отношений: Когда ты создаешь объект Category без дочерних элементов, свойство items не инициализируется пустым массивом автоматически. Оно остается nil. Это важно, потому что List в SwiftUI по-разному обрабатывает nil и пустой массив ([]).

List и children:: Параметр children: в List ожидает key path к свойству, которое содержит коллекцию дочерних элементов. Когда items равно nil, List не знает, есть ли у элемента дочерние элементы или нет. Поэтому он на всякий случай отображает стрелку раскрытия.

Сохранение и пустой массив После сохранения, SwiftData, при создании "fault" объекта (объекта, данные которого еще не загружены из БД), автоматически инициализирует свойство items пустым массивом [] для уже существующих (сохранённых) объектов, даже если дочерних элементов нет. Это стандартное поведение ORM-систем (Object-Relational Mapping, к которым относится SwiftData).

List видит []: Теперь List видит, что items — это пустой массив, и знает, что дочерних элементов нет. Но key path .items всё равно сообщает List, что потенциально дочерние элементы могут быть.

Решения:

Есть несколько способов решить эту проблему. Я покажу тебе наиболее эффективные и идиоматичные для SwiftUI и SwiftData:

  1. Использование вычисляемого свойства (Computed Property) (Рекомендуемый):

Это самый лучший способ. Мы добавим вычисляемое свойство, которое будет возвращать пустой массив, если items равно nil.

import SwiftData
import SwiftUI

@Model
class Category {
    @Attribute(.unique) var name: String
    @Relationship(deleteRule: .nullify, inverse: \Category.parent)
    var items: [Category]?
    var parent: Category?

    init(name: String, parent: Category? = nil) {
        self.name = name
        self.parent = parent
    }

    // Добавляем вычисляемое свойство
    var children: [Category] {
        items ?? []
    }
}

struct ContentView: View {
    @Environment(\.modelContext) var context
    @Query private var categories: [Category]

    var body: some View {
        VStack {
            // Используем новое вычисляемое свойство children
            List(categories.filter { $0.parent == nil }, children: \.children) { category in
                Text(category.name)
            }

            HStack {
                Button("Load data") {
                    let one = Category(name: "Parent one")
                    let two = Category(name: "Parent two")
                    let three = Category(name: "Parent three")

                    _ = Category(name: "Child one", parent: one)
                    _ = Category(name: "Child two", parent: one)
                    _ = Category(name: "Child three", parent: two)
                    _ = Category(name: "Child four", parent: two)
                    _ = Category(name: "Child five", parent: three)
                    _ = Category(name: "Child six", parent: three)

                    context.insert(one)
                    context.insert(two)
                    context.insert(three)

                    // Попробуй закомментировать/раскомментировать try? context.save()
                    // и посмотри на поведение.  Это поможет понять разницу между
                    // nil и [].
                     try? context.save()
                }
                Spacer()

                Button("Add item") {
                    // Добавь функциональность добавления, если нужно
                }
            }.padding(.horizontal)
        }
    }
}

Объяснение:

var children: [Category] { items ?? [] }: Это вычисляемое свойство. Оно не хранит данные, а вычисляет значение каждый раз, когда к нему обращаются.

items ?? []: Это оператор "nil coalescing". Он проверяет, равно ли items nil. Если да, то возвращает пустой массив ([]). Если нет, то возвращает значение items.

List(..., children: .children): Теперь мы используем key path .children в List. List всегда будет получать либо пустой массив, либо массив дочерних элементов, и будет правильно отображать стрелку.

  1. Инициализация items в init (Менее предпочтительный, но рабочий):

    @Model class Category { @Attribute(.unique) var name: String @Relationship(deleteRule: .nullify, inverse: \Category.parent) var items: [Category]? = [] // Инициализируем пустым массивом var parent: Category?

     init(name: String, parent: Category? = nil) {
         self.name = name
         self.parent = parent
     }
    

    }

    struct ContentView: View { @Environment(.modelContext) var context @Query private var categories: [Category]

     var body: some View {
         VStack {
             List(categories.filter { $0.parent == nil }, children: \.items) { category in
                 Text(category.name)
             }
    
             HStack {
                 Button("Load data") {
                     let one = Category(name: "Parent one")
                     let two = Category(name: "Parent two")
                     let three = Category(name: "Parent three")
    
                     _ = Category(name: "Child one", parent: one)
                     _ = Category(name: "Child two", parent: one)
                     _ = Category(name: "Child three", parent: two)
                     _ = Category(name: "Child four", parent: two)
                     _ = Category(name: "Child five", parent: three)
                     _ = Category(name: "Child six", parent: three)
    
                     context.insert(one)
                     context.insert(two)
                     context.insert(three)
                     // Попробуй закомментировать/раскомментировать try? context.save()
                     // и посмотри на поведение.
                      try? context.save()
                 }
                 Spacer()
                 Button("Add item") { }
             }.padding(.horizontal)
         }
     }
    

    }

Объяснение:

var items: [Category]? = []: Мы явно инициализируем свойство items пустым массивом при создании объекта Category.

Почему первый способ лучше:

Меньше изменений: Тебе не нужно менять логику init.

Более явное намерение: Вычисляемое свойство children ясно показывает, что ты хочешь получить дочерние элементы, и обрабатываешь случай, когда их нет.

Соответствие поведению SwiftData: Этот способ лучше отражает то, как SwiftData работает с отношениями "по умолчанию" (faulting).

Лучше для производительности Т.к. не создаётся лишних пустых массивов

  1. Использование OutlineGroup (Если нужна поддержка iOS 13/14):

Если тебе нужна поддержка более старых версий iOS (до iOS 15), List с children: может работать некорректно. В этом случае используй OutlineGroup:

import SwiftData
import SwiftUI

@Model
class Category {
    @Attribute(.unique) var name: String
    @Relationship(deleteRule: .nullify, inverse: \Category.parent)
    var items: [Category]?
    var parent: Category?

    init(name: String, parent: Category? = nil) {
        self.name = name
        self.parent = parent
    }
   var children: [Category] {
       items ?? []
   }
}


struct ContentView: View {
    @Environment(\.modelContext) var context
    @Query private var categories: [Category]

    var body: some View {
        VStack {
            // Используем OutlineGroup
            List {
                ForEach(categories.filter{$0.parent == nil}) { category in
                    OutlineGroup(category, children: \.children) { child in
                        Text(child.name)
                    }
                }
            }

            HStack {
                Button("Load data") {
                    let one = Category(name: "Parent one")
                    let two = Category(name: "Parent two")
                    let three = Category(name: "Parent three")

                    _ = Category(name: "Child one", parent: one)
                    _ = Category(name: "Child two", parent: one)
                    _ = Category(name: "Child three", parent: two)
                    _ = Category(name: "Child four", parent: two)
                    _ = Category(name: "Child five", parent: three)
                    _ = Category(name: "Child six", parent: three)

                    context.insert(one)
                    context.insert(two)
                    context.insert(three)

                    // Попробуй закомментировать/раскомментировать try? context.save()
                    // и посмотри на поведение.
                     try? context.save()
                }
                Spacer()

                Button("Add item") {
                    // Добавь функциональность, если нужно
                }
            }.padding(.horizontal)
        }
    }
}

Объяснение:

OutlineGroup: Этот компонент специально предназначен для отображения иерархических данных. Он работает с children key path аналогично List.

Ключевые выводы:

Понимай разницу между nil и пустым массивом ([]) в контексте SwiftData и SwiftUI.

Используй вычисляемые свойства (computed properties), чтобы обеспечить правильную обработку отношений в List (или OutlineGroup).

Инициализация items = [] в init — это обходной путь, но менее предпочтительный.

Я рекомендую использовать первый способ (с вычисляемым свойством children). Он самый чистый, эффективный и соответствует лучшим практикам работы со SwiftData и SwiftUI.

→ Ссылка