4.3 在列表、栈、网格和自定义布局中重新排序项目

原文链接: https://developer.apple.com/documentation/swiftui/reordering-items-in-lists-stacks-grids-and-custom-layouts

4.3 在列表、栈、网格和自定义布局中重新排序项目

使用重新排序修饰符为 SwiftUI 布局添加拖拽重新排序的交互。

概述

当你有一组或多组有序数据项目,且每个项目都在容器(例如列表、栈、网格或自定义布局)中显示为一个视图时,SwiftUI 会提供重新排序功能。系统允许用户选中某个表示数据项目的视图,并在容器中拖动它。当他们拖动该视图时,其他视图会移动,以显示一个可以放置该视图的占位空间。

当用户把视图放到占位位置上时,系统会提供与移动中的视图对应的数据项目标识符,以及这些视图的新位置。随后,你就可以在集合中移动与这些移动视图对应的数据项目,即使数组中同时发生了其他变化(例如云端同步)也没问题。

如果你把视图组织成由多个数据集合表示的区段——例如一个让用户把照片整理到相簿中的摄影应用——你可以配置重新排序,使其既支持在某个相簿内部移动照片,也支持把照片从一个相簿移动到另一个相簿。

在单个集合中重新排序项目

当你的视图由单个有序数据项目集合构建(例如摄影应用中的照片数组)时,首先为数据项目配置一个遵循 Hashable 和 Sendable 的标识符。例如,用一个遵循 Sendable 的标识符数据类型,让数据项目遵循 Identifiable。

接着,为生成这些视图的 ForEach 声明添加 reorderable() 修饰符,以指明你希望用户重新排序哪些视图,如下面代码所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@State private var photos: [Photo] = loadPhotos()

var body: some View {
    VStack {
        ForEach(photos) { photo in
            PhotoView(photo: photo)
        }
        .reorderable()
    }
}

最后,为外层列表、栈、网格或自定义布局容器添加 reorderContainer(for:isEnabled:move:) 修饰符,定义界面中允许用户重新排序的区域,从而指定参与重新排序的视图范围。在 move 闭包中,指明如何应用系统提供的、用于描述你的项目集合变化情况的 ReorderDifference,如下面这个代码示例所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@State private var photos: [Photo] = loadPhotos()

var body: some View {
    VStack {
        ...
    }
    .reorderContainer(for: Photo.self) { difference in
        move(difference: difference)
    }
}

跨多个集合重新排序项目

当你把视图组织成由多个数据集合构建的区段——例如摄影应用中各自包含照片的相簿——请为生成待排序视图的 ForEach 添加 reorderable(collectionID:)。然后,为包含这些视图对应数据项目的每个集合提供一个标识符,如下面这个示例所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
ScrollView {
    LazyVStack {
        ForEach(model.albumSections) { albumSection in
            Section(albumSection.title) {
                ForEach(albumSection.items) { photo in
                    PhotoView(photo: photo)
                }
                .reorderable(collectionID: albumSection.id)
            }
        }
    }
}

接着,为包含所有待排序区段和视图的列表、栈、网格或自定义布局添加 reorderContainer(for:in:isEnabled:move:),以定义重新排序区域的范围:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
ScrollView {
    LazyVStack {
        ...
    }
    .reorderContainer(
        for: Photo.self,
        in: AlbumSection.ID.self
    ) { difference in
        move(difference: difference)
    }
}

当用户在某个相簿内部移动视图,或把视图从一个相簿移动到另一个相簿时,系统会提供一个 ReorderDifference 实例,其中包含集合标识符和位置,你可以用它们来移动该视图对应的数据项目。

支持拖放到容器之外

为容器添加重新排序修饰符只支持在容器内部移动视图。要支持在容器与应用的其他部分或其他应用之间拖放,请使用拖放修饰符。

添加 dragContainer(for:in:_:) 修饰符,为有人把视图拖到容器之外时提供传输表示。例如,下面的代码展示了如何选中一个 Photo 实例,供系统在有人把照片拖到另一个窗口或应用时获取传输表示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@State private var photos: [Photo] = loadPhotos()

var body: some View {
    VStack {
        ...
    }
    .reorderContainer(for: Photo.self) { difference in
        move(difference: difference)
    }
    .dragContainer(for: Photo.self) { photoID in
        guard let photo = photos.first(where: { $0.id == photoID }) else {
            return []
        }
        return [photo]
    }
}

在 reorderContainer(for:isEnabled:move:) 修饰符之后添加 dropDestination(for:isEnabled:action:) 修饰符,以处理来自应用其他部分或其他应用的放置操作。当放置目标修饰符包住重新排序容器修饰符时,容器负责处理容器内部的拖放重新排序操作,而放置目标负责处理用户在容器之外发起的拖放操作。

在放置动作闭包内部,调用 reorderDestination(for:in:) 来找出用户把视图放到了相对于现有视图的什么位置。在下面的示例中,当该方法返回 ReorderDifference.Destination.Position.before(_:) 时,表示有人把一个或多个 Photo 表示放到了由 index 处的 Photo 实例所表示的视图之前。在这种情况下,你把被放置的项目插入到 index 处的项目之前。当该方法返回 ReorderDifference.Destination.Position.end 时,表示有人把一个或多个 Photo 表示放到了最后一个视图之后,因此你把被放置的项目追加到集合末尾。下面的代码演示了这两种情况:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@State private var photos: [Photo] = loadPhotos()

var body: some View {
    VStack {
        ...
    }
    .reorderContainer(for: Photo.self) { difference in
        move(difference: difference)
    }
    .dragContainer(for: Photo.self) { photoID in
        guard let photo = photos.first(where: { $0.id == photoID }) else {
            return []
        }
        return [photo]
    }
    .dropDestination(for: Photo.self) { items, session in
        if let destination = session.reorderDestination(for: Photo.ID.self),
           let index = model.index(for: destination) {
            photos.insert(contentsOf: items, at: index)
        } else {
            photos.append(contentsOf: items)
        }
        return true
    }
}

支持拖放到可重排容器中的单个项目上

为某个项目的视图添加 dropDestination(for:isEnabled:action:) 修饰符,让大家可以从其他应用直接把项目放到你容器中的某个特定项目上。下面的代码展示了如何为 ForEach 中的 ItemView 添加放置目标,而该 ForEach 同时已配置为支持重新排序:

1
2
3
4
5
6
7
8
HStack {
    ForEach(items) { item in
        ItemView(item)
            .dropDestination(for: Item.self, isEnabled: item.isFolder) { newItems, destination in ... }
    }
    .reorderable()
}
.reorderContainer(for: Item.self) { difference in ... }

使用 dropDestination(for:isEnabled:action:) 修饰符的 isEnabled 参数来限制该放置目标接受哪些项目。前面的代码示例展示了如何只在某个项目的 isFolder 属性为 true 时才接受放置。

在 dropDestination(for:isEnabled:action:) 修饰符的 action 闭包中添加代码,用被放置的项目更新你的模型,因为这与你在 reorderContainer(for:isEnabled:move:) 的闭包中处理重新排序时的模型更新方式可能不同。当你的放置目标接受来自重新排序容器的项目时,请务必在把这些项目移动到该项目的数据集合时,从来源数据集合中移除它们。

动态启用和停用重新排序

你可能希望通过网络同步更新时,或者在对视图进行其他会让重新排序变复杂的更新时停用重新排序。为此,请使用带 isEnabled 参数的重新排序容器修饰符变体,并向该参数传入一个布尔属性来跟踪是否启用了重新排序,如下面这个示例所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
ScrollView {
    LazyVStack {
        ...
    }
    .reorderContainer(
        for: Photo.self,
        isEnabled: !model.isSaving
    ) { difference in
        model.apply(difference: difference)
    }
}

在这个示例中,当 isSaving 为 true 时会停用重新排序。当模型完成保存并把 isSaving 改为 false 时,重新排序便会启用。