Списки: Вложенное меню.

February 21, 2022

В данной статье хочу описать как можно создать простое вложенное меню. Это удобно для представления любой иерархической структуры данных, позволяя пользователю разворачивать и сворачивать ветви для навигации по дереву.

👾 Внимание! Доступно только начиная с версии iOS 14.

Для начала создадим структуру для нашего меню:
struct FileItem: Identifiable {
    let id = UUID()
    let title: String
    var detail: [FileItem]?
    
    var icon: String {
        if detail == nil {
           return "doc.circle"
        } else {
           return "folder.circle"
        }
    }
}

Созданная структура должна удовлетворять двум условиям:

  • Быть Identifiable
  • Структурирована в виде дерева, что означает, что у каждого объекта должно быть свойство, представляющее собой необязательный массив того же типа. Это позволит рекурсивно строить дерево элементов и использовать nil, чтобы указать, является ли объект группой или элементом.

Заполним тестовыми данными:

let data = [
    FileItem(title: "Documents", detail: [
        FileItem(title: "Temp", detail: [
            FileItem(title: "Document 1.doc", detail: nil),
            FileItem(title: "Document 2.doc", detail: nil),
        ]),
        FileItem(title: "Presentation 1.pdf", detail: nil),
        FileItem(title: "Presentation 2.pdf", detail: nil),
        FileItem(title: "Presentation 3.pdf", detail: nil)
    ]),
    FileItem(title: "Photos", detail: [
        FileItem(title: "Photo 1.jpg", detail: nil),
        FileItem(title: "Photo 2.jpg", detail: nil)
    ]),
    FileItem(title: "Folder", detail: []),
    FileItem(title: "Another file.txt", detail: nil)
]

Чтобы отобразить наше меню добавим код в View. Для этого используем контейнер List и укажем в параметре children путь к свойству “detail” (т. е. то что является дочерними элементами).

List(data, children: \.detail) { item in
    Image(systemName: item.icon)
        .foregroundColor(.blue)
    Text(item.title)
}

Можно использовать метод OutlineGroup, но результат будет не такой красивый для нашей задачи. Необходимо добавить несколько модификаций для окончательного вида.

VStack(alignment: .leading) {
    OutlineGroup(data, children: \.detail) { item in
        HStack {
            Image(systemName: item.icon)
                .foregroundColor(.blue)
            Text(item.title)
            Spacer()
        }
    }
}
.padding()

Используая OutlineGroup, мы получаем представление иерархии данных с возможностью раскрытия. Это позволяет пользователю перемещаться по древовидной структуре, используя меню раскрытия для развертывания и сворачивания ветвей.

Усложнения задачи

Давайте доработаем пример и добавим возможность выделения паки или файла. Для этого необходимо изменить нашу структуру FileItem - преобразуем её в класс, тогда мы сможем вносить изменения в созданный объект. Добавим так же дополнительные свойства и инициализацию. Для упрощения примера для статьи свойство id установим в Int.

class FileItem: Identifiable {
    let id: Int
    let title: String
    var selected: Bool
    let parentID: Int
    var detail: [FileItem]?
    
    var icon: String {
        if detail == nil {
            return "doc"
        } else {
            return "folder"
        }
    }
    
    init(id: Int, title: String, selected: Bool,parentID: Int, detail: [FileItem]?) {
        self.id = id
        self.title = title
        self.selected = selected
        self.parentID = parentID
        self.detail = detail
    }
}

Незабудем обновить наши предустановленные данные.

var data = [
    FileItem(id: 1, title: "Documents", selected: false, parentID: 0, detail: [
        FileItem(id: 11, title: "Temp", selected: false, parentID: 1, detail: [
            FileItem(id: 21, title: "Document 1.doc", selected: false, parentID: 11, detail: nil),
            FileItem(id: 22, title: "Document 2.doc", selected: false, parentID: 11, detail: nil),
        ]),
        FileItem(id: 12, title: "Presentation 1.pdf", selected: false, parentID: 1, detail: nil),
        FileItem(id: 13, title: "Presentation 2.pdf", selected: false, parentID: 1, detail: nil),
        FileItem(id: 14, title: "Presentation 3.pdf", selected: false, parentID: 1, detail: nil)
    ]),
    FileItem(id: 2, title: "Photos", selected: false, parentID: 0, detail: [
        FileItem(id: 21, title: "Photo 1.jpg", selected: false, parentID: 2, detail: nil),
        FileItem(id: 22, title: "Photo 2.jpg", selected: false, parentID: 2, detail: nil)
    ]),
    FileItem(id: 3, title: "Folder", selected: false, parentID: 0, detail: []),
    FileItem(id: 4, title: "Another file.txt", selected: false, parentID: 0, detail: nil)
]

Для выполнения задачи мы используем схему разделения данных MVVM, поэтому добавим в наш проект ViewModel для интерпритации действий пользователя и оповещения модели о необходимости изменений.

class FileItemViewModel: ObservableObject {
    // Для хранения и изменения данных
    @Published var listOfItems: [FileItem] = []
    
    // Устновка или снятие признака выделения
    fileprivate func setSelected(_ item: FileItem, _ isChecked: Bool) {
        item.selected = isChecked
    }
    
    /// Изменяет признак выделения в полученном значении `item`, и
    /// если `item` содержит подчинённые элементы, то обрабатывает их тоже
    ///
    /// - Parameter
    ///     - item: Значение для обработки
    ///     - isChecked: Признак выделения
    ///
    func selectItem(item: FileItem, isChecked: Bool) {
        if let isParent = item.detail {
            setSelected(item, isChecked)
            _ = isParent.filter({ $0.parentID == item.id })
                .compactMap { (item: FileItem) in
                    setSelected(item, isChecked)
                }
        } else {
            setSelected(item, isChecked)
            _ = listOfItems.filter({ $0.id == item.parentID })
                .compactMap { (parent: FileItem) in
                    // Проверим если все подчинные элементы выделены, то выделяем и родителя
                    if parent.detail?.filter({ $0.selected == true }).count == parent.detail?.count {
                        parent.selected = true
                    } else if parent.detail?.filter({ $0.selected == false }).count == parent.detail?.count {
                        parent.selected = false
                    }
                    else {
                        parent.selected = true
                    }
                }
        }
    }
}

Для отображения ячейки с наименованием элемента добавим в проект новый файл, где описывается представление данных.

struct FileItemRow: View {
    @Binding var itemSelected: String
    var viewModel: FileItemViewModel // <-- Ссылка на наш контроллер, созданный выше
    
    var item: FileItem
    
    var body: some View {
        ZStack(alignment: .leading) {
            HStack(alignment: .center) {
                Button {
                    itemSelected = !item.selected ? "Selected \(item.title)" : "Deselected \(item.title)"
                    // Вызов функции изменения статуса выделения ячейки
                    viewModel.selectItem(item: item, isChecked: !item.selected)
                } label: {
                    // Картинка отображающая статус выделения ячейки
                    Image(systemName: item.selected ? "checkmark.square.fill" : "square")
                        .foregroundColor(item.selected ? .blue : .secondary)
                        .accessibility(label: Text(item.selected ? "Checked" : "Unchecked"))
                }
                
                // Вывод иконки и наименования элемента
                HStack {
                    Image(systemName: item.icon)
                        .foregroundColor(.blue)
                    Text(item.title)
                }
                Spacer(minLength: 10)
            }
            .padding(.trailing, 15)
        }
        .frame(height: 20, alignment: .center)
        .clipShape(RoundedRectangle(cornerRadius: 5))
    }
}

И на конец, конечный результат в нашем основном представлении ContentView Добавим возможность пролистывания, если данных будет много. Для каждого элемента массива идёт проверка, если есть подчинённые элементы, то мы используем метод OutlineGroup, иначе просто выводим данные элемента.

struct ContentView: View {
    @State var itemSelected = ""
    @ObservedObject var viewModel: FileItemViewModel
    
    var body: some View {
        VStack {
            Text(itemSelected)
            ScrollView(.vertical) {
                ForEach(data) { item in
                    DisclosureGroup(content: {
                        if let childrens = item.detail {
                            OutlineGroup(childrens, children: \.detail) { child in
                                FileItemRow(itemSelected: $itemSelected, viewModel: viewModel, item: child)
                            }
                            .padding(.leading, 20)
                        }
                        
                    }, label: { FileItemRow(itemSelected: $itemSelected, viewModel: viewModel, item: item) }
                    ).padding(.leading, 5)
                }
            }
        }
        .padding()
    }
}

В итоге всех доработок мы получаем довольно удобный список

Эту задачу можно ещё больше дорабатывать и улучшать для ваших нужд, но я постаралась обьяснить в целом. Спасибо за внимание и, надесь, этот пример поможет и вам 😀