精通 SwiftUI - iOS 16 版

第 28 章
如何建立展开式列表视图和大纲视图

SwiftUI 列表视图 (list view) 和 UIKit 的 UITableView 很类似。在 SwiftUI 最初的版本中,Apple 的工程师已经把建立列表视图的过程变得轻而易举,我们不需要创建 prototype cell,也不需要委派 (delegate) 或 data source 的协议。我们只需要用几行代码,就可以使用客制化单元格来建构一个列表视图。

在 iOS 14 (和以上版本),Apple 继续改善列表视图,并添加了一些新功能。在本篇教学文章中,我们将会看看如何建构一个展开式列表视图 (expandable list view) 或大纲视图 (outline view),并探索 inset grouped 的列表样式 (list style)。

示例 App

首先,让我们看看本篇教学的成品。我十分喜欢 La Marzocco,所以我用了它网站的导航菜单 (navigation menu) 为例子。以下的列表视图展示了菜单的大纲,使用者可以点击显示按钮来展开列表。

图 28.1. 展开式列表视图
图 28.1. 展开式列表视图

当然,你也可以用自己的实现方法,来建构这个大纲视图。但在最新版本的 SwiftUI 中,Apple 让开发者可以更简单地建构这种大纲视图,并自动适应于 iOS、iPadOS、和 macOS 版本。

建构大纲视图

要继续阅读本章的教学的话,请先从 https://www.appcoda.com/resources/swiftui/expandablelist-images.zip 下载示例图片。 然后,在 Xcode 使用 App 模板创建一个新的 SwiftUI 项目。 我将项目命名为 SwiftUIExpandableList,但您可以随意将名称设置为任何名称。

建立项目后,解压缩图像存档并将图像添加到 Assets。在项目导航器中,右键单击 SwiftUIExpandableList 并选择创建一个新文件档。 选择 Swift File 模板并将其命名为 MenuItem.swift

设置数据模型

要让列表视图可以展开,你只需要如此建立一个数据模型 (data model)。请加入以下代码至 MenuItem.swift:

struct MenuItem: Identifiable {
    var id = UUID()
    var name: String
    var image: String
    var subMenuItems: [MenuItem]?
}

在上面的代码中,我们有一个用来建造菜单物件的结构 (struct)。要创建一个巢状列表 (nested list),关键就是要加入一个属性,包含子级的可选数组 (optional array) subMenuItems。请注意,子级与其父级的类型 (type) 是一样的。

对于顶层 (top level) 菜单物件,我们可以如此创建一个 MenuItem 数组:

// Main menu items
let sampleMenuItems = [ MenuItem(name: "Espresso Machines", image: "linea-mini", subMenuItems: espressoMachineMenuItems),
                        MenuItem(name: "Grinders", image: "swift-mini", subMenuItems: grinderMenuItems),
                        MenuItem(name: "Other Equipment", image: "espresso-ep", subMenuItems: otherMenuItems)
                    ]

我们会针对每个菜单物件,指定子菜单 (sub-menu) 物件的数组。如果没有子菜单物件,则可以省略 subMenuItems 参数,或传递 nil 值。我们可以这样定义子菜单物件:

// Sub-menu items for Espressco Machines
let espressoMachineMenuItems = [ MenuItem(name: "Leva", image: "leva-x", subMenuItems: [ MenuItem(name: "Leva X", image: "leva-x"), MenuItem(name: "Leva S", image: "leva-s") ]),
                                 MenuItem(name: "Strada", image: "strada-ep", subMenuItems: [ MenuItem(name: "Strada EP", image: "strada-ep"), MenuItem(name: "Strada AV", image: "strada-av"), MenuItem(name: "Strada MP", image: "strada-mp"), MenuItem(name: "Strada EE", image: "strada-ee") ]),
                                 MenuItem(name: "KB90", image: "kb90"),
                                 MenuItem(name: "Linea", image: "linea-pb-x", subMenuItems: [ MenuItem(name: "Linea PB X", image: "linea-pb-x"), MenuItem(name: "Linea PB", image: "linea-pb"), MenuItem(name: "Linea Classic", image: "linea-classic") ]),
                                 MenuItem(name: "GB5", image: "gb5"),
                                 MenuItem(name: "Home", image: "gs3", subMenuItems: [ MenuItem(name: "GS3", image: "gs3"), MenuItem(name: "Linea Mini", image: "linea-mini") ])
                                ]

// Sub-menu items for Grinder
let grinderMenuItems = [ MenuItem(name: "Swift", image: "swift"),
                         MenuItem(name: "Vulcano", image: "vulcano"),
                         MenuItem(name: "Swift Mini", image: "swift-mini"),
                         MenuItem(name: "Lux D", image: "lux-d")
                        ]

// Sub-menu items for other equipment
let otherMenuItems = [ MenuItem(name: "Espresso AV", image: "espresso-av"),
                         MenuItem(name: "Espresso EP", image: "espresso-ep"),
                         MenuItem(name: "Pour Over", image: "pourover"),
                         MenuItem(name: "Steam", image: "steam")
                        ]

显示列表

准备好数据模型后,我们可以编写代码以呈现列表视图。List 视图现在有了可选的 children 参数。所以如果有任何子物件,你可以提供其 key path \.subMenuItems。 然后,SwiftUI 将递归 (recursively) 查找子菜单物件,并以大纲形式显示。打开 ContentView.swift 并在 body 中插入以下代码:

List(sampleMenuItems, children: \.subMenuItems) { item in
    HStack {
        Image(item.image)
            .resizable()
            .scaledToFit()
            .frame(width: 50, height: 50)

        Text(item.name)
            .font(.system(.title3, design: .rounded))
            .bold()
    }
}

List 视图闭包中,我们会描述每一行的外观。在示例代码中,我们使用了 HStack 布局图像和文本视图。如果你已经在 ContentView 中正确地添加了代码的话,SwiftUI 应该会如此呈现大纲视图:

图 28.2. 展开式列表视图
图 28.2. 展开式列表视图

要测试App,请在模拟器或预览画布中运行它。 您可以点击披露指示器以显示子菜单。

使用普通列表样式

在 iOS 15 中,Apple 将列表视图的默认样式设置为 Inset Grouped,其中分组的部分以圆角嵌入。 如果要将其切换回普通列表样式,可以将 .listStyle 修饰器附加到 List 视图并将其值设置为 .plain,如下所示:

List {
  ...
}
.listStyle(.plain)

如果你没有加错位置,列表视图现在应该修改为普通样式。

图 28.3. 使用普通列表样式
图 28.3. 使用普通列表样式

使用 Inset Grouped 列表样式

正如您在前面的示例中所见,使用 List 视图创建大纲视图非常容易。 但是,如果您想更好地控制大纲视图的外观(例如添加部分标题),则需要使用 OutlineGroup。 这个新视图是自 iOS 14 才引入的,用于呈现数据的层次结构。

如果您已明白如何构建可扩展的列表视图,“OutlineGroup”的用法也非常相似。 例如,以下代码允许您构建相同的可扩展列表视图,如图 28.1 所示:

List {
    OutlineGroup(sampleMenuItems, children: \.subMenuItems) {  item in
        HStack {
            Image(item.image)
                .resizable()
                .scaledToFit()
                .frame(width: 50, height: 50)

            Text(item.name)
                .font(.system(.title3, design: .rounded))
                .bold()
        }
    }
}

List 视图类似,您只需要在建立OutlineGroup 时传入要显示的项目并指定子菜单项(或子项)的键路径(Key path)。

有了 OutlineGroup,您可以更好地控制大纲视图的外观。 例如,我们希望将顶级菜单项显示为节标题。 你可以这样写代码:

List {
    ForEach(sampleMenuItems) { menuItem in

        Section(header:
            HStack {

                Text(menuItem.name)
                    .font(.title3)
                    .fontWeight(.heavy)

                Image(menuItem.image)
                    .resizable()
                    .scaledToFit()
                    .frame(width: 30, height: 30)

            }
            .padding(.vertical)

        ) {
            OutlineGroup(menuItem.subMenuItems ?? [MenuItem](), children: \.subMenuItems) {  item in
                HStack {
                    Image(item.image)
                        .resizable()
                        .scaledToFit()
                        .frame(width: 50, height: 50)

                    Text(item.name)
                        .font(.system(.title3, design: .rounded))
                        .bold()
                }
            }
        }
    }
}

在上面的代码中,我们使用 ForEach 来遍历菜单项。 我们将顶层项目呈现为节标题。 对于其余的子菜单项,我们依靠“OutlineGroup”来创建数据层次结构。 如果您修改 ContentView.swift内的代码,您应该会看到如图 28.4 所示的大纲视图。

图 28.4. 使用 OutlineGroup 构建大纲视图
图 28.4. 使用 OutlineGroup 构建大纲视图

同样地,如果您更喜欢使用普通列表样式,可以将 listStyle 修饰器附加到 List 视图:

.listStyle(.plain)

然后你会得到图 28.5 的结果。

图 28.5. 使用普通列表样式
图 28.5. 使用普通列表样式

了解 DisclosureGroup

在大纲视图中,您可以通过点击显示指示器来显示/隐藏子菜单。 无论您使用 List 还是 OutlineGroup 来实现可展开列表,iOS 14 中引入的名为 DisclosureGroup 的新视图都支持这种“展开和折叠”功能。

公开组视图(Disclosure Group View)是为显示或隐藏另一个内容视图而设计。 虽然 DisclosureGroup 会自动嵌入到 OutlineGroup 中,但您也可以独立使用此视图。 例如,您可以使用以下代码来显示和隐藏问题和答案:

DisclosureGroup(
    content: {
        Text("Absolutely! You are allowed to reuse the source code in your own projects (personal/commercial). However, you're not allowed to distribute or sell the source code without prior authorization.")
            .font(.body)
            .fontWeight(.light)
    },
    label: {
        Text("1. Can I reuse the source code?")
            .font(.body)
            .bold()
            .foregroundColor(.black)
    }
)

公开组视图有两个参数:labelcontent。 在上面的代码中,我们在 label 参数中指定了问题,在 content 参数中指定了答案。 图 28.6 显示了结果。

图 28.6. 利用 DisclosureGroup 显示和隐藏内容
图 28.6. 利用 DisclosureGroup 显示和隐藏内容

在默认的情况下,公开组视图处于隐藏模式。 要显示内容视图,请点击显示指示器(>)以将显示组视图切换到“展开”状态。

另外,您可以通过传一个绑定来控制 DisclosureGroup 的状态,该绑定直接控制披露指示器的状态(展开或折叠),如下所示:

struct FaqView: View {
    @State var showContent = true

    var body: some View {
        DisclosureGroup(
            isExpanded: $showContent,
            content: {
                ...
            },
            label: {
                ...
            }
        )
        .padding()
    }
}

练习

DisclosureGroup 视图允许您更好地控制披露指标的状态。 你的练习是建立一个类似于图 28.7 所示的 FAQ 屏幕。

图 28.7. 练习
图 28.7. 练习

使用者可以点击披露指示器来显示或隐藏单个问题。 此外,App也提供了一个“Show all”按钮来展开所有问题并立即显示答案。

总结

在本章中,我介绍了 SwiftUI 的一些新功能。 正如您在演示中看到的那样,构建大纲视图或可扩展列表视图一点也不难。 您需要做的就是定义一个正确的数据模型。 List 视图处理其余部分并呈现大纲视图。 最重要的是,新的修改提供了 OutlineGroupDisclosureGroup 供您进一步客制化大纲视图。

在本章所准备的示例档中,有最后完整的 Xcode 项目,可供你下载参考:

https://www.appcoda.com/resources/swiftui4/SwiftUIExpandableList.zip

请注意,您可以参考 FaqView.swift 来获得练习的解决方案。