SwiftUI List 模式参考目录ForEach 身份与稳定性枚举序列带自定义样式的 List带下拉刷新的 List使用 ContentUnavailableView 的空状态iOS 17自定义 List 背景Table汇总清单ForEach 身份与稳定性始终为ForEach提供稳定的身份。对于动态内容绝不要使用.indices。// 好 - 通过 Identifiable 提供稳定身份extensionUser:Identifiable{varid:String{userId}}ForEach(users){userinUserRow(user:user)}// 好 - 通过 keypath 提供稳定身份ForEach(users,id:\.userId){userinUserRow(user:user)}// 错误 - indices 创建静态内容ForEach(users.indices,id:\.self){indexinUserRow(user:users[index])// 移除时可能崩溃}// 错误 - 不稳定的身份ForEach(users,id:\.self){userinUserRow(user:user)// 仅当 User 是 Hashable 且稳定时才有效}关键确保ForEach中每个元素对应的视图数量恒定// 好 - 一致的视图数量ForEach(items){iteminItemRow(item:item)}// 坏 - 可变的视图数量破坏身份ForEach(items){iteminifitem.isSpecial{SpecialRow(item:item)DetailRow(item:item)}else{RegularRow(item:item)}}避免内联过滤// 坏 - 身份不稳定每次更新都会变化ForEach(items.filter{$0.isEnabled}){iteminItemRow(item:item)}// 好 - 预过滤并缓存StateprivatevarenabledItems:[Item][]varbody:someView{ForEach(enabledItems){iteminItemRow(item:item)}.onChange(of:items){_,newItemsinenabledItemsnewItems.filter{$0.isEnabled}}}避免在列表行中使用AnyView// 坏 - 隐藏身份、增加开销ForEach(items){iteminAnyView(item.isSpecial?SpecialRow(item:item):RegularRow(item:item))}// 好 - 创建一个带单一顶级容器的统一行视图ForEach(items){iteminItemRow(item:item)}structItemRow:View{letitem:Itemvarbody:someView{// VStack 让行保持一元一个顶级视图这样// List 无需评估每行的 body 即可模板化行 id。VStack{ifitem.isSpecial{SpecialRow(item:item)}else{RegularRow(item:item)}}}}原因稳定的身份对性能和动画至关重要。不稳定的身份会导致过度 diff、动画损坏和潜在崩溃。在List中优先使用一元行List需要提前知道每行的身份。当每行的 body 产生单个顶级视图一元行时SwiftUI 可以仅凭ForEach元素的 id 模板化行 id而无需运行每行的body。当 body 在不同顶级形状之间分支时——裸顶级switch、无else的顶级if、或AnyView——结构身份会随行而变化因此 SwiftUI 会退化为评估每行的 body 来计算 id。该开销随行数扩展。解决办法是将分支内容包裹在任何单根容器中VStack、HStack、ZStack或自定义包装器使行始终恰好是一个顶级视图如上所示。无else的顶级if也是多视图0 或 1 个视图如果某些元素根本不应成为行请在集合到达ForEach之前进行过滤而不是生成零视图行。要在现有应用中找出非常量行构建器可使用-LogForEachSlowPath YES启动SwiftUI 会记录惰性容器内每个行 body 产生非常量视图数量的ForEach。保持 id 稳定、唯一且计算廉价还有三条身份规则可以防止细微的 bugid 必须比视图存活更久并且编辑时不能改变。不要从可变属性派生id例如var id: String { title }。编辑标题会改变 id因此 SwiftUI 会将其视为一次移除加一次插入——焦点和行级状态会在编辑过程中丢失。使用稳定的let id: UUID或服务器分配的键。不要在body内部合成新的 id。ForEach(items.map { Item(title: $0) })会在每次 body 求值时创建新的UUID因此整个集合在每次更新时都会被视为替换。在比body存活更久的存储中模型层一次性创建 id而不是内联创建。保持 id 哈希计算廉价。避免对大型Hashable结构体使用id: \.self哈希会在每次 diff 时遍历每个字段。使用小型原始类型UUID、Int、短String、URL并仍将完整元素传递给行。Identifiable ID 必须真正唯一非唯一的 ID 会导致 SwiftUI 将不同项目视为相同导致重复渲染或视图缺失// Bug -- 两个 URL 相同的文章显示相同内容structArticle:Identifiable{lettitle:Stringleturl:URLvarid:String{url.absoluteString}// 如果 URL 重复就不唯一}// 修复 -- 使用真正唯一的标识符structArticle:Identifiable{letid:UUIDlettitle:Stringleturl:URL}类在遵循Identifiable而未提供 id 时会获得基于ObjectIdentifier的默认id。它仅在对象的生命周期内唯一并且可能在释放后被回收。枚举序列使用.enumerated()没问题只是索引不能作为身份。将\.offset用作 id 与对items.indices使用\.self是同样的反模式——id 变成位置而不是元素因此插入和重排会重置行状态并破坏动画。保持元素自身的身份作为 id并将索引视为普通行数据。// 错误 - offset 是位置不是元素ForEach(items.enumerated(),id:\.offset){index,iteminItemRow(number:index1,item:item)}// 正确 - id 来自元素索引只是数据ForEach(items.enumerated(),id:\.element.id){index,iteminItemRow(number:index1,item:item)}在 Swift 6.1 上不需要Array(...)包装。从 Swift 6.1 开始当基础集合遵循RandomAccessCollection时.enumerated()返回的序列会条件性地遵循RandomAccessCollection因此ForEach可以直接接受它。在更早的工具链上用Array(...)包裹它。在新代码中优先使用直接形式——它避免在每次 body 求值时进行急切拷贝。带自定义样式的 List// 移除默认背景和分隔线List(items){iteminItemRow(item:item).listRowInsets(EdgeInsets(top:8,leading:16,bottom:8,trailing:16)).listRowSeparator(.hidden)}.listStyle(.plain).scrollContentBackground(.hidden).background(Color.customBackground).environment(\.defaultMinListRowHeight,1)// 允许自定义行高带下拉刷新的 ListList(items){iteminItemRow(item:item)}.refreshable{awaitloadItems()}使用 ContentUnavailableView 的空状态iOS 17对空列表/搜索状态使用ContentUnavailableView。内置的.search变体是自动本地化的List{ForEach(searchResults){iteminItemRow(item:item)}}.overlay{ifsearchResults.isEmpty,!searchText.isEmpty{ContentUnavailableView.search(text:searchText)}}对于非搜索的空状态使用自定义实例ContentUnavailableView(No Articles,systemImage:doc.richtext.fill,description:Text(Articles you save will appear here.))自定义 List 背景使用.scrollContentBackground(.hidden)替换默认的列表背景List(items){iteminItemRow(item:item)}.scrollContentBackground(.hidden).background(Color.customBackground)没有.scrollContentBackground(.hidden)时自定义.background()在List上不会产生可见效果。Table可用性iOS 16.0、iPadOS 16.0、visionOS 1.0一个多列数据容器以可排序、可选择的列呈现Identifiable数据的行。在紧凑尺寸类别iPhone、iPad Slide Over下第一列之后的列会自动隐藏。基本 TablestructPerson:Identifiable{letgivenName:StringletfamilyName:StringletemailAddress:StringletidUUID()varfullName:String{givenName familyName}}structPeopleTable:View{Stateprivatevarpeople:[Person][/* ... */]varbody:someView{Table(people){TableColumn(Given Name,value:\.givenName)TableColumn(Family Name,value:\.familyName)TableColumn(E-Mail Address,value:\.emailAddress)}}}带选择的 Table绑定单个ID进行单选或绑定SetID进行多选structSelectableTable:View{Stateprivatevarpeople:[Person][/* ... */]StateprivatevarselectedPeopleSetPerson.ID()varbody:someView{Table(people,selection:$selectedPeople){TableColumn(Given Name,value:\.givenName)TableColumn(Family Name,value:\.familyName)TableColumn(E-Mail Address,value:\.emailAddress)}Text(\(selectedPeople.count)people selected)}}可排序的 Table提供[KeyPathComparator]的绑定并在.onChange(of:)中重新排序数据structSortableTable:View{Stateprivatevarpeople:[Person][/* ... */]StateprivatevarsortOrder[KeyPathComparator(\Person.givenName)]varbody:someView{Table(people,sortOrder:$sortOrder){TableColumn(Given Name,value:\.givenName)TableColumn(Family Name,value:\.familyName)TableColumn(E-Mail Address,value:\.emailAddress)}.onChange(of:sortOrder){_,newOrderinpeople.sort(using:newOrder)}}}重要Table不会自行对数据排序——你必须在sortOrder变化时重新排序集合。适配紧凑尺寸类别的 Table在 iPhone 或 Slide Over 模式的 iPad 上只显示第一列。自定义它以显示组合信息structAdaptiveTable:View{Environment(\.horizontalSizeClass)privatevarhorizontalSizeClassprivatevarisCompact:Bool{horizontalSizeClass.compact}Stateprivatevarpeople:[Person][/* ... */]StateprivatevarsortOrder[KeyPathComparator(\Person.givenName)]varbody:someView{Table(people,sortOrder:$sortOrder){TableColumn(Given Name,value:\.givenName){personinVStack(alignment:.leading){Text(isCompact?person.fullName:person.givenName)ifisCompact{Text(person.emailAddress).foregroundStyle(.secondary)}}}TableColumn(Family Name,value:\.familyName)TableColumn(E-Mail Address,value:\.emailAddress)}.onChange(of:sortOrder){_,newOrderinpeople.sort(using:newOrder)}}}带静态行的 Table当行在编译时已知时使用init(of:columns:rows:)structPurchase:Identifiable{letprice:DecimalletidUUID()}structTipTable:View{letcurrencyStyleDecimal.FormatStyle.Currency(code:USD)varbody:someView{Table(of:Purchase.self){TableColumn(Base price){purchaseinText(purchase.price,format:currencyStyle)}TableColumn(With 15% tip){purchaseinText(purchase.price*1.15,format:currencyStyle)}TableColumn(With 20% tip){purchaseinText(purchase.price*1.2,format:currencyStyle)}}rows:{TableRow(Purchase(price:20))TableRow(Purchase(price:50))TableRow(Purchase(price:75))}}}列数动态的 Table可用性iOS 17.4、iPadOS 17.4、Mac Catalyst 17.4、macOS 14.4、visionOS 1.1如果运行时不知道列数使用TableColumnForEach基于某种数据类型的RandomAccessCollection创建列。集合元素必须遵循Identifiable或者你需要向TableColumnForEach初始化器提供 id 参数。这可以与编译时已知的静态TableColumn用法混合使用。structAudioChannel:Identifiable{letname:Stringletid:UUID}structAudioSample:Identifiable{letid:UUIDlettimestamp:TimeIntervalfunclevel(channel:AudioChannel.ID)-Double{1}}ObservableclassAudioSampleTrack{letchannels:[AudioChannel]varsamples:[AudioSample]}structContentView:View{vartrack:AudioSampleTrackvarbody:someView{Table(track.samples){TableColumn(Timestamp (ms)){sampleinText(sample.timestamp,format:.number.scale(1000)).monospacedDigit()}TableColumnForEach(track.channels){channelinTableColumn(channel.name){sampleinText(sample.level(channel:channel.id),format:.number.precision(.fractionLength(2))).monospacedDigit()}.width(ideal:70).alignment(.numeric)}}}}Table 样式// Inset无边框Table(people){/* columns */}.tableStyle(.inset)// 隐藏列标题Table(people){/* columns */}.tableColumnHeaders(.hidden)平台行为平台行为iPadOS常规完整多列布局标题和所有列可见iPadOS紧凑只显示第一列标题隐藏iPhone所有尺寸只显示第一列标题隐藏类似列表的外观最佳实践优先通过在第一列显示组合信息来处理紧凑尺寸类别。这在尺寸类别变化时例如在 iPad 上进入/退出 Slide Over提供无缝过渡。汇总清单ForEach 使用稳定身份动态内容绝不用.indices或\.offsetIdentifiable ID 在所有项目中真正唯一id 在编辑时保持稳定不从可变属性派生、在body之外创建、且哈希廉价每个 ForEach 元素的视图数量恒定行是一元的单个顶级视图ForEach 中无内联过滤改为预过滤并缓存列表行中无AnyView.enumerated()使用元素的 id而不是\.offsetSwift 6.1 上无需Array(...)包装下拉刷新使用.refreshable空状态使用ContentUnavailableViewiOS 17自定义列表背景使用.scrollContentBackground(.hidden)Table适配紧凑尺寸类别第一列显示组合信息Table排序在.onChange(of: sortOrder)中重新排序数据table 不会自行排序Table数据遵循Identifiable