В данной статье хочу описать как можно создать простое вложенное меню. Это удобно для представления любой иерархической структуры данных, позволяя пользователю разворачивать и сворачивать ветви для навигации по дереву.
👾 Внимание! Доступно только начиная с версии 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"
}
}
}
Созданная структура должна удовлетворять двум условиям:
Заполним тестовыми данными:
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()
}
}
В итоге всех доработок мы получаем довольно удобный список
Эту задачу можно ещё больше дорабатывать и улучшать для ваших нужд, но я постаралась обьяснить в целом. Спасибо за внимание и, надесь, этот пример поможет и вам 😀