swiftui-expert-skill - list-patterns

发布时间:2026/10/11 5:32:44

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){_,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
延伸阅读

更多相关文章

2026/10/11 5:32:44

np.where在电磁近场测量数据处理中的实战应用

做电磁近场测量,最烦的不是架探头、对位、设步进,而是扫完一圈之后,面对那一大坨幅相矩阵。数据里经常藏着各种不老实的东西:线缆一抖,某个点幅度飙到 99.9;放大器瞬间饱和,相位从 179 跳到 -17…

2026/10/11 5:32:44

智元是时候看回启元了

文 | 白笈编 | 沈校“(具身智能公司)预期被拉低,买账的人少了。关键要看做账水平怎么样,如何处理关联交易。”有分析人士对数智奔流表示。智元的港股IPO正在变难。据报道,监管机构正通过非正式窗口指导暂缓部分人形机器…

2026/10/11 6:27:45

Linux 日志增量统计:inode + offset 方案(不丢不重)

背景 我给 Nginx 缓存命中率写了个统计脚本,每 5 分钟跑一次,读 /var/log/nginx/dashboard_cache.log,统计 HIT/MISS 数量写进 MariaDB。 第一版逻辑很简单: tail -n 100 /var/log/nginx/dashboard_cache.log | awk {...}跑了两天…

2026/10/11 6:27:45

Web 安全自学完整路线,从零搭建属于自己的渗透测试知识体系

Web 安全自学完整路线,从零搭建属于自己的渗透测试知识体系 摘要 很多想要入行网络安全、学习 Web 渗透测试的小伙伴,刚开始都会陷入迷茫:不知道先学什么,网上资料杂乱零散,教程东拼西凑,学了很久依旧不会实…

2026/10/11 6:27:45

Python网易云歌单分析:从爬虫采集到数据可视化全流程

简介:面向大学编程课程与 Python 数据分析初学者,这份压缩包收录了一个基于网易云音乐歌单场景的完整分析项目:从 requests 爬取歌单数据,到 pandas 清洗整理,再到 matplotlib、squarify、wordcloud 等库绘制评论、收藏…

2026/10/11 6:22:45

内网渗透踩坑实录:域环境下高频攻击手段与防御排查全梳理

内网渗透踩坑实录:域环境下高频攻击手段与防御排查全梳理 摘要 内网域环境是绝大多数中大型企业真实网络架构,也是护网行动、红队评估的主战场。很多渗透测试人员 Web 漏洞打得很熟练,但进入域环境之后频频踩坑:横向移动失败、票据…

2026/10/11 0:02:13

Python调用Gemini Structured Outputs实现工单路由门禁

客服工单最怕的不是模型“答错一句话”,而是它给出一段看起来合理的说明,程序却从中猜错优先级。通俗做法是:要求模型只交 JSON(JavaScript Object Notation,轻量数据格式),再让代码验证它。Gem…

2026/10/11 0:02:13

Spring Boot超市进销存系统毕设实战:从需求拆解到答辩通关

最近带的一个学生项目组里,有A同学跑来问我:选什么毕设题目最稳妥,既能让评审老师觉得工作量够,又不会在答辩时被问到语无伦次。我第一反应就是推荐基于Spring Boot的超市仓库管理系统——也就是超市进销存系统。这个题目乍一看平…

2026/10/11 0:02:13

Flutter StatefulWidget 生命周期核心解析

很多刚开始接触 Flutter 的朋友,在看完一堆“Hello World”和基础组件之后,大概率都会撞上同一堵墙:StatefulWidget 里那堆 initState、build、dispose 方法,到底什么时候被调用?为什么顺序是那样?在里面到…

2026/10/11 0:02:13

Python调用Gemini Structured Outputs实现工单路由门禁

客服工单最怕的不是模型“答错一句话”,而是它给出一段看起来合理的说明,程序却从中猜错优先级。通俗做法是:要求模型只交 JSON(JavaScript Object Notation,轻量数据格式),再让代码验证它。Gem…

2026/10/11 0:02:13

Spring Boot超市进销存系统毕设实战:从需求拆解到答辩通关

最近带的一个学生项目组里,有A同学跑来问我:选什么毕设题目最稳妥,既能让评审老师觉得工作量够,又不会在答辩时被问到语无伦次。我第一反应就是推荐基于Spring Boot的超市仓库管理系统——也就是超市进销存系统。这个题目乍一看平…

2026/10/11 0:02:13

Flutter StatefulWidget 生命周期核心解析

很多刚开始接触 Flutter 的朋友,在看完一堆“Hello World”和基础组件之后,大概率都会撞上同一堵墙:StatefulWidget 里那堆 initState、build、dispose 方法,到底什么时候被调用?为什么顺序是那样?在里面到…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑