ListRow 列表的每一行

1. 行的常用样式

区别于放在列表后面的 listStyle ,以下修饰符都是放在列表的【每一行视图】后面的。另外如果有四行元素都要设置背景,是需要逐个添加的,没有办法添加到父元素 List 上,不过在 ForEach 视图下就设置一次就可以。ForEach 实际上类似于给它们包了一层。

List{

	NavigationLink{ ... }
		// 设置该行的背景色
		.listRowBackground(Color.darkBackground)

	Text("All Projects")
		// 把该行的背景色设置成透明
	  .listRowBackground(Color.clear)
		
	Text("All Projects")
		// 隐藏该行的分割线  
		.listRowSeparator(.hidden)
		
		// 要调整分隔符的颜色
		.listRowSeparatorTint(.red)
	
	Text("All Projects")                            
		// 设置该行四个方向上的缩进                            
		.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 0))   

}

有一个专用的 listItemTint() 修饰符,用于控制列表如何为其行着色。确切的行为取决于您的应用程序运行的平台,但代码是相同的。

	Text("All Projects")
		// 例如,这会将偶数行染成红色,奇数行染成绿色
		.listItemTint(i.isMultiple(of: 2) ? .red : .green)
		
// 但该修饰符具体的作用取决于平台:
// 在 iOS 上,这会将图标更改为红色和绿色,但保留文本的原色
// 在 macOS 上,这将图标更改为红色和绿色,还会覆盖用户首选的强调色
// 在 watchOS 上,会将行背景颜色(称为“背景盘”)更改为红色或绿色
// 在 tvOS 上它什么也不做。

// 在 macOS 上,您可以尊重用户的强调色,同时添加您自己喜欢的列表行色调,如下所示:
List(1..<51) { i in
    Label("Row \\(i)", systemImage: "\\(i).circle")
        .listItemTint(.preferred(i.isMultiple(of: 2) ? .red : .green))
}

2. 给行添加徽章

// 列表行可以添加 badge 徽章,它会自动显示为辅助颜色的右对齐文本
List {
    Text("Wi-Fi")
        .badge("LAN Solo")

    Text("Bluetooth")
        .badge("On")
}

3. 不同行展示不同样式

如果想获得混合效果的列表(例如列表的第一项用了特殊样式),可以采用加额外判断的方式实现。

List(restaurants.indices) { index in
    if (0...1).contains(index) {
        FullImageRow(restaurant: self.restaurants[index])
    } else {
        BasicImageRow(restaurant: self.restaurants[index])
    }
}

4. 调整行的分隔符的间距

SwiftUI 会自动调整列表行分隔符的间距,使其与文本前缘对齐,并提供了 .listRowSeparatorLeading  和  .listRowSeparatorTrailing  的对齐辅助线,让你可以根据需要自定义。

// 例如,您可以将分隔符的前缘设置为整行的前缘,这意味着分隔符将与所有内容对齐,而不仅仅是文本部分:
List(0..<51) { i in
    Label("Row \\(i)", systemImage: "\\(i).circle")
        .alignmentGuide(.listRowSeparatorLeading) { d in
            d[.leading]
        }
}

// 该示例中,使用 0 而不是 d[.leading] 应该具有相同的效果
// 或者,您可以根据与您的设计相匹配的值使用自定义值:
List(0..<51) { i in
    Label("Row \\(i)", systemImage: "\\(i).circle")
        .alignmentGuide(.listRowSeparatorLeading) { _ in
            100
        }
}

// 您还可以自定义列表行分隔符的后缘,作为前导行分隔符插入的补充或替代。
// 例如,此代码通过将 “分隔符末尾间距和内容边缘对齐” ,从而实现行分隔符仅位于行文本下方:
List(0..<51) { i in
    Label("Row \\(i)", systemImage: "\\(i).circle")
        .alignmentGuide(.listRowSeparatorTrailing) { d in
            d[.trailing]
        }
}

ListRow 行的操作

行的删除&移动(简单版)

如果您只想让用户以滑动的方式从数组中删除项目,而不添加任何其他逻辑,那么这个删除方法非常有效。具体按照以下两步操作:

// 设置完成后,用户可以立即通过滑动来删除行,并且 users 数组将在用户执行此操作时更新
struct ContentView: View {
    @State private var users = ["Glenn", "Malcolm", "Nicola", "Terri"]
    var body: some View {
        NavigationStack {
            List($users, id: \\.self, editActions: .delete) { $user in
                Text(user)
            }
        }
    }
}

// 如果希望在数组中移动项目,而不添加任何额外逻辑,那 editActions 参数的值改成
List($users, id: \\.self, editActions: .move)

// 如果希望列表既支持删除,也支持移动,那 editActions 参数的值改成
List($users, id: \\.self, editActions: .all) 
// 如果想禁用某一行的删除,请在具体行后面使用 deleteDisabled() 修饰符
// 例如,我们可以说,只要始终至少剩余 1 行,用户就可以从列表中自由删除:
struct ContentView: View {
    @State private var users = ["Glenn", "Malcolm", "Nicola", "Terri"]
    var body: some View {
        NavigationStack {
            List($users, id: \\.self, editActions: .delete) { $user in
                Text(user)
                    .deleteDisabled(users.count < 2)
            }
        }
    }
}

// 如果想禁用某一行的移动,请使用 moveDisabled() 修饰符
// 这时这一行就无法长按拖动,利用这个我们可以制作 “比如置顶的项目就不支持拖动排序” 的效果
Text(user)
		.moveDisabled(user == "Glenn")

行的删除&移动(复杂版)

具体参见:单个列表行设置

// 该处理程序需要具有接受要删除的多个索引的特定签名,如下所示:
struct ContentView: View {
    @State private var users = ["Paul", "Taylor", "Adele"]
    var body: some View {
        NavigationStack {
            List {
                ForEach(users, id: \\.self) { user in
                    Text(user)
                }
                // 删除方法
                .onDelete(perform: delete)
                // 移动方法
                .onMove(perform: move)
            }
            .navigationTitle("Users")
        }
    }
    
    // 删除方法:
    // 通常需要调用 remove(atOffsets:) 方法来从序列中删除请求的行
		// 由于 SwiftUI 正在监视您的状态,因此所做的任何更改都会自动反映在 UI 中
    func delete(at offsets: IndexSet) {
        users.remove(atOffsets: offsets)
    }
    
    // 移动方法:
    // 移动多个项目时,最好先移动后面的项目,这样可以避免移动其他项目并导致索引混乱
    // 幸运的是,Swift 的序列有一种内置的方法来为我们移动索引集,因此我们只需传递参数即可使其正常工作
    func move(from source: IndexSet, to destination: Int) {
		    users.move(fromOffsets: source, toOffset: destination)
		}
}

禁用“点击整行触发”

当【表单Form】或【列表List】中有行时,SwiftUI 喜欢假设【整行】本身是可点击的。这使得用户可以更轻松地进行选择,因为他们可以点击行中的任意位置来触发其中的按钮。在下面例子中,我们有多个按钮,因此 SwiftUI 按顺序点击所有按钮 - rating 设置为 1,然后是 2,然后是 3、4 和 5,这就是为什么它最终都是 5 。

// contentView
@State var rating: Int = 3
Form{
	Section("Write a review"){
		RatingView(rating: $rating)
	}
}

// subView
@Binding var rating: Int
HStack {
	ForEach(1..<maximumRating + 1, id: \\.self) {
			number in
				Button {
					rating = number
					print(rating)
				} label: {
					image(for: number).foregroundStyle(number > rating ? offColor : onColor)
			}
	}
}

// 这时,可以通过在子视图中附加到整个 HStack 的额外修饰符,来禁用整个“点击行以触发其按钮”行为:
HStack { ... }
.buttonStyle(.plain)

swipeActions 行的滑动

swipeActions() 修饰符让您可向列表行添加一个或多个滑动操作按钮,并且控制它们属于哪一侧,以及是否应使用完全滑动来触发它们。

// 默认情况下,按钮将放置在行的右边缘,并且没有任何颜色
// 因此当您从右向左滑动时,将显示一个灰色按钮:
List {
    Text("Taylor Swift")
        .swipeActions {
            Button("Send message", systemImage: "message") {
                print("Hi")
            }
        }
}

// 如果要定义按钮位置,请使用 edge 参数
// 如果想在行的任一侧添加不同的滑动操作,只需使用不同的边缘调用 swipeActions() 两次即可
.swipeActions(edge: .leading) { ... }
.swipeActions(edge: .trailing) { ... }

// 如果要自定义菜单按钮的颜色:一是使用 Tint() 修饰符,一是通过指定按钮的语义角色
// 对于真正具有破坏性的按钮,应该使用 Button(role: .destructive) 而不是仅仅指定红色
List {
    Text("Taylor Swift")
        .swipeActions {
            Button("Delete", systemImage: "minus.circle", role: .destructive) {
                print("Deleting")
            }
						.tint(.orange)
        }
}
// 默认情况下,如果用户滑动得足够远,将自动触发第一个滑动操作。
// 如果您想禁用此功能,请在创建滑动操作时将 allowsFullSwipe 设置为 false
List {
		ForEach(friends, id: \\.self) { friend in
				Text(friend)
						.swipeActions(allowsFullSwipe: false) {
                            Button {
                                print("Muting conversation")
                            } label: {
                                Label("Mute", systemImage: "bell.slash.fill")
                            }
                            .tint(.indigo)

                            Button(role: .destructive) {
                                print("Deleting conversation")
                            } label: {
                                Label("Delete", systemImage: "trash.fill")
                            }
				}
		}
}

<aside> 💡 滑动操作是添加额外功能的绝佳功能,但它们与之前使用的 onDelete() 修饰符不能很好配合。因此,需要手动设置“删除”功能。 在使用 swipeActions 操作具体列表对象时,往往需要搭配 tag 修饰符,这个非常重要。原因如下:

</aside>

tag 修饰符

tag 修饰符的作用是为了标识可选择的视图,例如 PickerTabView 中的可能值,或者在 List 中标识选定的项目。

为什么要加上 tag 修饰符呢?因为在 List 中,我们需要标识每个项目以便进行选择。tag 修饰符允许将 prospect 对象与 List 中的选定项目进行关联,从而使得我们可以在用户与列表交互时识别和处理所选项目。

List(filteredProspects, selection: $selectedProspects) { 
		prospect in
    NavigationLink {
        ProspectDetailView(prospect: prospect)
    } label: {
        // Your view content here
    }
    .swipeActions {
        // Your swipe actions here
    }
    .tag(prospect) // 使用tag修饰符标识每个prospect对象
}

<aside> 💡 当用户与列表中的项目进行交互(如选择、滑动等)时,SwiftUI需要知道具体是哪个项目被选中或操作。添加 tag 修饰符可以帮助识别和跟踪每个项目。我的理解是,这里列表的 selection 参数绑定的是一个集合 set ,这部分没有 ID 标识出来,因此需要额外地添加 tag 修饰符,来支持 .swipeActions 的删除操作

</aside>


ListRow 行的选择

有时候点击列表项,只是需要先做“选择”,以便选择完后再采取统一操作。List 的项目支持单选和多选,但仅当列表处于编辑模式时才支持。

支持单选

让列表项支持单选一般需要 3 个步骤:

1. 状态属性存储选择项

要支持单选,请先声明与 “在列表中使用的类型” 相同的可选状态属性。

struct ContentView: View {

		// 例如:以下列表展示的是 String 数据,那声明的【选择项】应该为 String?
		// 默认情况下它不会选择任何内容,但当点击具体项时,它会包含用户姓名
		@State private var selection: String?

    let users = ["Tohru", "Yuki", "Kyo", "Momiji"]
    var body: some View {
		    // users 数组也是字符串
        List(users, id: \\.self) { user in
            Text(user)
        }
    }
}

2. 列表双向绑定状态属性

接下来需要告诉列表在点击一行时更新该状态属性,即双向绑定。这代表点击具体行会更新【选择项】,而更新【选择项】也会选择具体行。

List(users, id: \\.self, selection: $selection){
		user in
    Text(user)
}

3. 使用【选择项】的数据

最后,可以以某种方式使用该【选择项】数据。

//例如,如果【选择项】有数据,我们可以在列表下方显示文本:
if let selection {
    Text("You selected \\(selection)")
}

支持多选

List 还可以让用户同时选择多行,并且一次性操作它们。如果想让 List 支持选择多个项目,则需要做以下更改:

1. 更改状态属性类型

更改 selection 状态属性的类型,以便它能存储一组值(而不是单个值),这里统一将其声明为集合 Set 。

为什么声明为集合而不是数组?因为集合是无序的,且不能重复,符合选择列表项的要求。

//例如,把状态属性的类型改成集合。默认情况下可以为空,这意味着未选择任何内容:
@State private var selection = Set<String>()

2. 启用多选 EditButton

真正的挑战是:如何启用多项选择?因为默认当用户点击一行时,SwiftUI 会自动取消上一个选择项。iOS 有一个隐藏的手势来激活多选模式:如果用两根手指在数据上水平滑动,它将激活。如果使用模拟器,则需要按住 Option 键以启用两指模式,然后按住 Shift 键以启用滑动,然后在列表上从左向右滑动。虽然这两种方法都有效,但都不是很明显。

因此更好的方法是:添加 EditButton ,它会自动处理启用或禁用编辑,因此也启用或禁用多选模式。

// 显示时,我们可以在集合上调用 formatted() 将所有名称显示为单个字符串:
if selection.isEmpty == false {
    Text("You selected \\(selection.formatted())")
}

// 最后,只需要将以下代码放在布局中的某个位置:
// 现在您应该能够自由地进入和退出多选模式,然后点击每个列表行旁边的复选框将其添加到您的选择中。然后您如何处理您的选择取决于您!
EditButton()

3. 完整例子

//1. 首先添加一个状态属性,储存选择的行的集合
@State private var selectedProspects = Set<Prospect>()

//2. 然后将该选择绑定到列表:
List(prospects, selection: $selectedProspects) { prospect in ...}

//重要提示:最后为了帮助 SwiftUI 了解 List 中的每一行对应一个潜在对象,需要添加以下代码:
List(prospects, selection: $selectedProspects) { 
	prospect in
	NavigationLink{ ... }
		.tag(prospect)
}

//使用时,定义一个【删除所有选择行的】函数
func delete() {
    for prospect in selectedProspects {
        modelContext.delete(prospect)
    }
}

//在Toolbar中添加相应的按钮进行调用(使用EditButton打开多选模式)
ToolbarItem(placement: .topBarLeading) {
    EditButton()
}

if selectedProspects.isEmpty == false {
    ToolbarItem(placement: .bottomBar) {
        Button("Delete Selected", action: delete)
    }
}