在SwiftUI中递归构建菜单

3

问题

我最近发现 SwiftUI 的 OutlineGroup 在 iOS 14 中(我正在使用 Xcode 12 beta 6)。它可以很好地工作,无论是独立使用还是与 List 一起使用,可以从树形结构的标识数据的基础集合中“按需计算视图和披露组”。

也就是说,如果您有一个递归定义的 struct,则可以很好地使用它来构建 DisclosureGroup 元素。但是我正在寻找一些略有不同的内容,这将允许我构建一个“下拉式”(或汉堡)菜单。

iOS 14 中还有另一个名为 Menu 的控件,它以完全符合我的要求的“下拉式”(或汉堡)菜单呈现:

enter image description here

然而,我似乎无法同时使用两者来基于递归表示的数据构建动态 Menu,例如:

struct Tree<Value: Hashable>: Hashable {
    let value: Value
    var children: [Tree]? = nil
}

下面是使用以下方式构建的菜单:

struct SideMenu: View {    
    var body: some View {        
        Menu {
            Button(action: {}) {
                Image(systemName: "person")
                    .foregroundColor(.gray)
                    .imageScale(.large)
                Text("Profile")
                    .foregroundColor(.gray)
                    .font(.headline)
            }
            Button(action: {}) {
                Image(systemName: "person.3")
                    .foregroundColor(.gray)
                    .imageScale(.large)
                Text("Family Members")
                    .foregroundColor(.gray)
                    .font(.headline)
            }
            Button(action: {}) {
                Image(systemName: "calendar")
                    .foregroundColor(.gray)
                    .imageScale(.large)
                Text("Events")
                    .foregroundColor(.gray)
                    .font(.headline)
            }
        } label: {
            Image(systemName: "line.horizontal.3")
        }
    }
}

问题

是否有一种方法可以从递归数据构建菜单,类似于使用 OutlineGroup 构建的方式?

1个回答

5

我喜欢使用枚举类型表示树型结构,以避免出现不可能或不一致的状态。此外,你需要进行递归UI函数调用,但使用方法会导致编译器出错(我的Xcode 12 beta 6),所以我将菜单部分分别放在不同的视图中,这样似乎可以解决问题。现在,你拥有完全动态的菜单,可以从你的ViewModel中构建。

import SwiftUI

enum ViewEvent {
    case profileTapped
    case familyMembersTapped
    case eventsTapped
    case foldersTapped
    case deletedItemsTapped
}

struct MenuItem: Identifiable {
    var id: String { return text }
    let text: String
    let systemImage: String?
    let action: ViewEvent?
}

enum MenuContent: Identifiable {
    var id: String {
        switch self {
        case let .item(item): return item.id
        case let .submenu(text, _): return text
        }
    }

    case item(MenuItem)
    indirect case submenu(text: String, content: [MenuContent])
}

struct ViewState {
    let menu: [MenuContent]
    let content: String

    static var `default`: ViewState {
        .init(
            menu: [
                .item(MenuItem(text: "Profile", systemImage: "person", action: .profileTapped)),
                .item(MenuItem(text: "Family Members", systemImage: "person.3", action: .familyMembersTapped)),
                .item(MenuItem(text: "Events", systemImage: "calendar", action: .familyMembersTapped)),
                .submenu(text: "More", content: [
                    .item(MenuItem(text: "Folders", systemImage: "folder.fill", action: .foldersTapped)),
                    .item(MenuItem(text: "Deleted", systemImage: "trash.fill", action: .deletedItemsTapped))
                ])
            ],
            content: "Content")
    }
}

struct ContentView: View {
    @State var viewState: ViewState

    var body: some View {
        HStack(alignment: .top, spacing: 16) {
            AppMenu(contents: viewState.menu) {
                Image(systemName: "line.horizontal.3")
            }

            Text("Content")
        }.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
        .padding()
    }
}

struct AppMenuItem: View {
    let item: MenuItem

    func dispatch(_ action: ViewEvent) {
        // todo: call viewModel.dispatch
        print("Sending action \(action)")
    }

    init(item: MenuItem) {
        self.item = item
    }

    var body: some View {
        Button(action: {
            item.action.map { action in dispatch(action) }
        }) {
            item.systemImage.map { systemImage in
                Image(systemName: systemImage)
                    .foregroundColor(.gray)
                    .imageScale(.large)
            }

            Text(item.text)
                .foregroundColor(.gray)
                .font(.headline)
        }
    }
}

struct AppSubmenu: View {
    let text: String
    let contents: [MenuContent]

    var body: some View {
        AppMenu(contents: contents) {
            HStack {
                Text(text)
                Image(systemName: "chevron.right")
            }
        }
    }
}

struct AppMenu<Label: View>: View {
    let label: () -> Label
    let contents: [MenuContent]

    init(contents: [MenuContent], @ViewBuilder label: @escaping () -> Label) {
        self.contents = contents
        self.label = label
    }

    var body: some View {
        Menu {
            ForEach(contents) { content in
                // In case this is an item
                if case let .item(item) = content {
                    AppMenuItem(item: item)
                }

                // In case this is a submenu
                if case let .submenu(text, contents) = content {
                    AppSubmenu(text: text, contents: contents)
                }
            }
        } label: { label() }
    }
}

2
这个很棒。如果我将一些Action/State定义移到AppMenu扩展下面,编译器会抱怨。但是,除此之外,它正好做到了我所需要的:对菜单数据的递归遍历。谢谢! - Nick
使用ViewBuilder与func一起使用而不是不同的View会更好,这样将所有内容放在相同的View结构中将允许重用相同的ViewModel。但是当我在Xcode 12 beta 6上尝试时,编译器崩溃了,等我有时间时我会打开一个FB。 - Luiz Barbosa
不需要那么复杂。只需遵循正常的递归方法,返回函数的类型是包装在AnyView中的菜单或按钮。当然,使用了Foreach的方法。 - Simon
这个方法非常有效,而且可以轻松地适用于不同的数据类型。 - undefined

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接