
别让刷新把人送回顶部:用 AI + SwiftUI scrollPosition 记住阅读位置
这一技教你让 AI 用 SwiftUI scrollPosition(id:) 绑定稳定条目 ID,在列表刷新、插入或删除数据后保留用户的阅读位置,并用 Xcode 独立验收回退规则和连续刷新边界。
刷新列表后,用户还应该停在原来的位置
一个阅读列表刚打开时没问题,用户滑到中间,点了刷新,页面却回到顶部。用户只好重新找刚才看到的内容;如果刷新同时插入了新条目,这次寻找甚至不一定能靠记忆完成。
今天这招解决的是「数据更新后,用户不应该丢掉当前阅读位置」:让 AI 用 SwiftUI 的
scrollPosition(id:) 记录当前滚动目标,并让每一行使用稳定的业务 ID。Apple 将 scrollPosition 定义为描述滚动视图语义位置的类型,scrollPosition(id:anchor:) 则把一个绑定连接到滚动视图,让绑定在滚动时更新。1 2本文按 iOS 17.0 及以上的 SwiftUI API 写。如果项目的 Deployment Target 低于 iOS 17,先让 AI 检查兼容方案,不要因为当前模拟器能编译就直接提高最低版本。
先定清楚「刷新后保留什么」
scrollPosition 记录的是某个滚动目标的 ID,不是屏幕上的像素坐标。所以第一步不是改代码,而是确定列表里的哪一项代表用户正在看的位置。| 产品场景 | 当前项的身份 | 当前项被删除时怎么办 |
|---|---|---|
| 资讯流 | 文章的服务端 ID | 停在删除项后面第一项,找不到时回到列表顶部 |
| 最近项目 | 项目的稳定 ID | 停在项目列表中距离原位置最近的项目 |
| 消息列表 | 会话或消息的稳定 ID | 显示一条已读提示,再回到最新消息 |
今天用「资讯流」举例。先写下四条规则:
- 每篇文章的 ID 来自数据本身,不使用数组下标。
- 刷新可以插入新文章,但当前文章仍存在时,阅读位置尽量跟着它。
- 当前文章已经被删除时,必须有明确的回退项,不能让页面停在一个不存在的 ID 上。
- 文章排序、分页和加载失败提示保持原样,不因为加入滚动位置而重写。
这和上一期的
ScrollViewReader 用法不一样。提交表单失败时,ScrollViewReader 适合主动跳到一个错误字段;今天的场景是持续记录列表当前落在哪个条目,供刷新后继续判断应该停在哪里。把产品规则交给 AI
把列表 View、数据模型、项目的最低 iOS 版本和上面的规则一起交给 AI。可以直接使用下面这段指令,再替换页面名和数据类型:
请只改造「资讯流」的滚动位置保持能力,不要重写数据请求、分页、排序、文章卡片和点击导航。当前产品规则:用户刷新后,如果当前文章仍在新数据中,尽量继续停在这篇文章附近;如果当前文章被删除,回退到删除项后面的第一篇文章,找不到时回到第一篇。新插入的文章不能让用户无故回到顶部。请使用 SwiftUI iOS 17.0 及以上的scrollPosition(id:)和scrollTargetLayout()。列表行必须使用文章自身的稳定 ID,不要用数组下标当 ID。先解释哪些视图 modifier 会移动,哪些状态会新增,再给出最小改动代码。刷新完成后,请保留旧的当前文章 ID,检查它是否仍存在,再决定继续使用旧 ID 还是使用回退 ID。不要用固定延迟、手写 offset 或GeometryReader模拟这件事。最后列出 iPhone 窄屏、大字号、插入新文章和当前文章被删除时的验收步骤。
这段指令里最重要的是「当前项被删除怎么办」。如果不先定回退规则,AI 往往只会把一个可选 ID 绑到滚动视图上,却没有处理数据更新后目标消失的情况。
看懂 AI 应该改成什么
最小结构可以接近下面这样,
FeedItem、FeedRow 和 reloadItems() 换成你项目里已有的类型和加载函数:struct FeedView: View {
@State private var visibleItemID: FeedItem.ID?
@State private var items: [FeedItem] = []
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(items) { item in
FeedRow(item: item)
.id(item.id)
}
}
.scrollTargetLayout()
}
.scrollPosition(id: $visibleItemID)
.refreshable {
let oldID = visibleItemID
let newItems = await reloadItems()
items = newItems
visibleItemID = fallbackID(keeping: oldID, in: newItems)
}
}
}这里不用背代码,盯住五个位置就够了:
visibleItemID存的是文章 ID,不是第几行,也不是屏幕坐标。ForEach里的每一行要有稳定身份。模型已经遵守Identifiable时,通常沿用它的id;不要让 AI 改成ForEach(items.indices)只为快速通过编译。.scrollTargetLayout()放在承载重复文章行的LazyVStack上。Apple 文档说明,这个 modifier 会把外层布局配置成滚动目标布局。3.scrollPosition(id: $visibleItemID)放在ScrollView上,绑定才有机会随着滚动更新,也能由状态反向指定目标。- 刷新完成后先更新数据,再检查旧 ID 是否仍存在。
fallbackID是产品规则的代码化,不要让 AI 用newItems.first作为所有场景的默认答案。
如果 AI 只加了
.scrollPosition(id:),却没有 .scrollTargetLayout(),让它解释滚动目标是怎样被识别的。如果它用数组下标保存位置,也要退回修改,因为插入或删除一条数据后,同一个下标已经不再代表同一篇文章。用 Xcode 验收「没跳位」
先给列表准备固定假数据,文章标题要有长有短,数据顺序也要能在刷新前后变化。不要只用五条完全相同的占位文本,否则很难判断你看到的是同一篇文章,还是恰好回到了相似的位置。
按下面顺序验收:
- 打开资讯流,滑到中间,记住当前文章的标题或首句。
- 触发刷新,确认顶部可以出现新文章,但当前文章仍在可见区域附近,页面没有直接回到顶部。
- 在当前文章之前插入两条假数据,再刷新一次,确认位置跟着文章 ID,而不是跟着原来的行号。
- 删除当前文章,再刷新,确认页面落到产品规则指定的后继文章;如果后继文章不存在,确认回退到第一篇。
- 刷新请求失败时,确认旧列表和当前滚动位置仍然保留,错误提示不会清空内容。
- 在 iPhone 窄屏和 iPad 横屏上重复前面的步骤,确认行高、分页和加载提示没有因为绑定滚动位置而变化。
- 打开大字号,检查长标题、多行摘要和图片加载中的文章,确认用户仍能识别当前文章。
- 连续快速刷新两次,确认最后一次数据与位置状态没有被前一次结果覆盖。这个问题属于数据请求管理,要单独验收,不能只看滚动 modifier。
人工检查 AI 的差异说明时,重点看三件事:它有没有把数据 ID 换成数组下标,是否在当前项消失后留下了无效 ID,以及是否为了「看起来稳定」偷偷加入定时滚动或固定延迟。后两种写法可能在演示里有效,遇到真实网络速度和数据变化就会变脆。
这招的边界
scrollPosition(id:) 给的是语义位置,不是「永远保持屏幕像素不动」的承诺。刷新后如果当前条目高度发生变化、当前条目被删除,或者列表完全换成另一组数据,页面应该落在哪里,仍然需要产品规则和回退逻辑。它也不负责处理异步请求的先后关系。比如用户连续触发两次刷新,旧请求晚于新请求返回,仍可能覆盖列表数据;这要让 AI 另外检查请求取消、版本号或并发策略。滚动位置绑定解决的是「当前看哪一项」,不是所有刷新竞态。
项目最低版本低于 iOS 17 时,先记录旧系统方案、提高最低版本的成本,以及不支持语义滚动位置时的体验差异,再决定是否合入。不要把模拟器里一次成功的刷新当成完整验收。
PM 今天可以独立完成的清单
- 找一个刷新后经常回到顶部的真实列表。
- 写下列表项的稳定 ID,以及当前项被删除时的回退规则。
- 把现有 View、数据模型、最低 iOS 版本和产品规则一起交给 AI。
- 检查每一行使用的是业务 ID,不是数组下标。
- 检查
.scrollTargetLayout()是否挂在承载重复行的布局容器上。 - 检查
.scrollPosition(id:)是否绑定到了ScrollView的状态。 - 用插入、删除、失败和连续刷新假数据验收位置是否符合规则。
- 在 iPhone 窄屏、iPad 横屏和大字号下重复测试。
- 如果项目低于 iOS 17,先记录兼容方案和取舍,再决定是否合入。
先改一个资讯流页面。验收标准很具体:刷新可以改变数据,但不应该把用户刚看到的那篇文章凭空送回列表顶部。
Related content
- Sign in to comment.
