news 2026/10/11 5:32:38

swiftui-expert-skill - list-patterns

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
swiftui-expert-skill - list-patterns

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){_,newItemsinenabledItems=newItems.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 稳定、唯一且计算廉价

还有三条身份规则可以防止细微的 bug:

  • id 必须比视图存活更久,并且编辑时不能改变。不要从可变属性派生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:index+1,item:item)}// 正确 - id 来自元素;索引只是数据ForEach(items.enumerated(),id:\.element.id){index,iteminItemRow(number:index+1,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)// 允许自定义行高

带下拉刷新的 List

List(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)下,第一列之后的列会自动隐藏。

基本 Table

structPerson:Identifiable{letgivenName:StringletfamilyName:StringletemailAddress:Stringletid=UUID()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进行单选,或绑定Set<ID>进行多选:

structSelectableTable:View{@Stateprivatevarpeople:[Person]=[/* ... */]@StateprivatevarselectedPeople=Set<Person.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:Decimalletid=UUID()}structTipTable:View{letcurrencyStyle=Decimal.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或\.offset)
  • Identifiable ID 在所有项目中真正唯一
  • id 在编辑时保持稳定(不从可变属性派生)、在body之外创建、且哈希廉价
  • 每个 ForEach 元素的视图数量恒定;行是一元的(单个顶级视图)
  • ForEach 中无内联过滤(改为预过滤并缓存)
  • 列表行中无AnyView
  • .enumerated()使用元素的 id(而不是\.offset);Swift 6.1+ 上无需Array(...)包装
  • 下拉刷新使用.refreshable
  • 空状态使用ContentUnavailableView(iOS 17+)
  • 自定义列表背景使用.scrollContentBackground(.hidden)
  • Table适配紧凑尺寸类别(第一列显示组合信息)
  • Table排序在.onChange(of: sortOrder)中重新排序数据(table 不会自行排序)
  • Table数据遵循Identifiable
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/11 5:29:10

Agent技能体系落地:从Function Calling到可复用技能库的工程实践

agent-skills 这事&#xff0c;说白了就是给大模型智能体配一个可复用的"技能工具箱"。我最近在一个面向内部知识库问答的模拟项目X里完整落地了一套技能编排体系&#xff0c;从最初的十几个散装技能函数&#xff0c;到后来沉淀成可注册、可调度、可观测的技能库&…

作者头像 李华
网站建设 2026/10/11 5:27:44

Pot(派了个萌的翻译器):划词翻译 × 截图OCR 上手指南

Pot&#xff08;派了个萌的翻译器&#xff09;&#xff1a;划词翻译 截图OCR 上手指南 【免费下载链接】pot-desktop &#x1f308;一个跨平台的划词翻译和OCR软件 | A cross-platform software for text translation and recognition. 项目地址: https://gitcode.com/GitHu…

作者头像 李华