SwiftUI的滚动视图:GeometryReader与 PreferenceKey的问题
我正在打造一个SwiftUI风格的“记分牌”界面:一个ScrollView,顶部只有一个固定头部。随着滚动,固定头部应更新显示当前活动的分区(NBA/NHL等),类似于一个粘性“当前分类”标签。
我目前的做法是:
- 每个分区在顶部插入一个GeometryReader
- 每个GeometryReader将一个标记(id、minY)写入一个PreferenceKey
- onPreferenceChange会在每帧读取所有标记,基于阈值找到活动的分区,并更新一个 @State的 activeLeagueID
- 固定头部读取activeLeagueID并更新其标签
用少量的模拟数据时,这个方法可以工作,但在真实数据下(分区更多、行数更多、实时更新)我看到:
- 滚动过程中会出现卡顿,有时滚动后会把ScrollView向上或向下微调(我在想这是来自PreferenceKey的副作用)
-
偶尔会出现运行时警告:
-
Bound preference
tried to update multiple times per frame
问题
是否有一个 更好/更高效的方式来实现这个“滚动时的活动分区”行为?
具体来说:
- 是否有GeometryReader + PreferenceKey的替代方案,能够避免逐帧的偏好更新频繁churn?
- 如果这种做法是“正确的”,如何降低工作量(对标记进行排序、减少频繁的状态更新等),以防止“每帧多次更新”的警告?
完整示例
import SwiftUI
private struct CustomStyleTestSectionMarker: Equatable {
let id: String
let minY: CGFloat
}
private struct CustomStyleTestSectionMarkerPreferenceKey: PreferenceKey {
static var defaultValue: [CustomStyleTestSectionMarker] = []
static func reduce(value: inout [CustomStyleTestSectionMarker], nextValue: () -> [CustomStyleTestSectionMarker]) {
value.append(contentsOf: nextValue())
}
}
struct CustomStyleTest: View {
@Environment(\.colorScheme) private var colorScheme
@State private var activeLeagueID: String?
@State private var sections: [MockLeagueSection] = MockLeagueSection.sampleData
private static let headerHeight: CGFloat = 48
private static let scrollCoordinateSpace = "custom-style-test-scroll"
private static let inListHeaderApproxHeight: CGFloat = 30
var body: some View {
ScrollView(showsIndicators: false) {
customSectionsContent
}
.coordinateSpace(name: Self.scrollCoordinateSpace)
.onAppear {
syncActiveLeagueID(with: sections)
}
.onChange(of: sections.map(\.id)) { _, _ in
syncActiveLeagueID(with: sections)
}
.onPreferenceChange(CustomStyleTestSectionMarkerPreferenceKey.self) { markers in
guard let candidate = activeLeagueID(from: markers) else { return }
if candidate != activeLeagueID {
activeLeagueID = candidate
}
}
.clipShape(
.rect(
topLeadingRadius: 30,
bottomLeadingRadius: 30,
bottomTrailingRadius: 30,
topTrailingRadius: 30,
style: .continuous
)
)
.padding(.horizontal, 12)
.ignoresSafeArea(edges: .bottom)
.navigationTitle("Custom Style Test")
.navigationBarTitleDisplayMode(.inline)
}
private var customSectionsContent: some View {
LazyVStack(spacing: 12, pinnedViews: [.sectionHeaders]) {
Section {
VStack {
ForEach(Array(sections.enumerated()), id: \.element.id) { index, section in
customSectionRow(section: section, index: index)
.id(section.id)
}
}
.padding()
} header: {
staticHeader
}
}
.scrollTargetLayout()
.background {
containerShape
.fill(containerFill)
.overlay {
containerShape
.stroke(
colorScheme == .dark
? Color.white.opacity(0.16)
: Color.white.opacity(0.45),
lineWidth: 0.8
)
}
.overlay {
containerShape
.stroke(
Color.black.opacity(colorScheme == .dark ? 0.28 : 0.08),
lineWidth: 0.45
)
}
}
.clipShape(containerShape)
.padding(.bottom, 100)
}
private func customSectionRow(section: MockLeagueSection, index: Int) -> some View {
VStack(alignment: .leading, spacing: 0) {
sectionTopMarker(id: section.id)
if index > 0 {
inListSectionHeader(section: section)
.padding(.bottom, 10)
}
VStack(spacing: 0) {
ForEach(Array(section.games.enumerated()), id: \.element.id) { gameIndex, game in
mockGameRow(game: game)
if gameIndex < section.games.count - 1 {
Divider()
.padding(.vertical, 2)
}
}
}
}
}
private func sectionTopMarker(id: String) -> some View {
Color.clear
.frame(height: 0)
.background {
GeometryReader { proxy in
Color.clear.preference(
key: CustomStyleTestSectionMarkerPreferenceKey.self,
value: [
CustomStyleTestSectionMarker(
id: id,
minY: proxy.frame(in: .named(Self.scrollCoordinateSpace)).minY
)
]
)
}
}
}
private func inListSectionHeader(section: MockLeagueSection) -> some View {
VStack(spacing: 0) {
Divider()
.padding(.vertical, 4)
.padding(.horizontal, -16)
sectionHeaderLabel(for: section)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private var staticHeader: some View {
if let section = activeSection {
sectionHeaderLabel(for: section)
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: Self.headerHeight, alignment: .center)
.background {
headerShape
.fill(containerFill)
.overlay {
headerShape
.stroke(
colorScheme == .dark
? Color.white.opacity(0.16)
: Color.white.opacity(0.45),
lineWidth: 0.8
)
}
.overlay {
headerShape
.stroke(
Color.black.opacity(colorScheme == .dark ? 0.28 : 0.08),
lineWidth: 0.45
)
}
}
.background(Color(.systemBackground))
.allowsHitTesting(false)
}
}
private func sectionHeaderLabel(for section: MockLeagueSection) -> some View {
HStack(spacing: 8) {
Image(systemName: section.symbolName)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Text(section.title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Image(systemName: "chevron.right")
.font(.caption.weight(.bold))
.foregroundStyle(.secondary)
}
}
private func mockGameRow(game: MockGame) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(game.statusText)
.font(.footnote.weight(.semibold))
.foregroundStyle(game.isLive ? .red : .secondary)
Spacer()
}
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 6) {
teamLine(abbreviation: game.awayAbbreviation, name: game.awayName)
teamLine(abbreviation: game.homeAbbreviation, name: game.homeName)
}
Spacer(minLength: 12)
VStack(alignment: .trailing, spacing: 6) {
Text("\(game.awayScore)")
.font(.title3.weight(.semibold))
.foregroundStyle(game.isLive ? .primary : .secondary)
Text("\(game.homeScore)")
.font(.title3.weight(.semibold))
.foregroundStyle(game.isLive ? .primary : .secondary)
}
}
}
.padding(.vertical, 10)
}
private func teamLine(abbreviation: String, name: String) -> some View {
HStack(spacing: 8) {
Text(abbreviation)
.font(.headline.weight(.semibold))
.foregroundStyle(.primary)
.frame(width: 60, alignment: .leading)
Text(name)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
private func syncActiveLeagueID(with currentSections: [MockLeagueSection]) {
guard !currentSections.isEmpty else {
activeLeagueID = nil
return
}
guard let current = activeLeagueID else {
activeLeagueID = currentSections.first?.id
return
}
if !currentSections.contains(where: { $0.id == current }) {
activeLeagueID = currentSections.first?.id
}
}
private func activeLeagueID(from markers: [CustomStyleTestSectionMarker]) -> String? {
guard !markers.isEmpty else { return nil }
let sorted = markers.sorted { $0.minY < $1.minY }
let threshold = max(0, Self.headerHeight - Self.inListHeaderApproxHeight)
if let current = sorted.last(where: { $0.minY <= threshold }) {
return current.id
}
return sorted.first?.id
}
private var activeSection: MockLeagueSection? {
guard !sections.isEmpty else { return nil }
guard let activeLeagueID else { return sections.first }
return sections.first(where: { $0.id == activeLeagueID }) ?? sections.first
}
private var containerShape: RoundedRectangle {
RoundedRectangle(cornerRadius: 30, style: .continuous)
}
private var headerShape: UnevenRoundedRectangle {
UnevenRoundedRectangle(
cornerRadii: .init(
topLeading: 30,
bottomLeading: 0,
bottomTrailing: 0,
topTrailing: 30
),
style: .continuous
)
}
private var containerFill: LinearGradient {
if colorScheme == .dark {
return LinearGradient(
colors: [
Color.white.opacity(0.07),
Color.white.opacity(0.07)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
} else {
return LinearGradient(
colors: [
Color.white.opacity(0.95),
Color(white: 0.965).opacity(0.92)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
}
}
private struct MockLeagueSection: Identifiable {
let id: String
let title: String
let symbolName: String
let games: [MockGame]
static let sampleData: [MockLeagueSection] = [
MockLeagueSection(
id: "nba",
title: "NBA",
symbolName: "basketball.fill",
games: [
MockGame(id: "nba-1", awayAbbreviation: "GSW", awayName: "Warriors", homeAbbreviation: "LAL", homeName: "Lakers", awayScore: 109, homeScore: 113, statusText: "Final", isLive: false),
MockGame(id: "nba-2", awayAbbreviation: "BOS", awayName: "Celtics", homeAbbreviation: "MIA", homeName: "Heat", awayScore: 62, homeScore: 60, statusText: "6:12 3rd", isLive: true),
MockGame(id: "nba-3", awayAbbreviation: "NYK", awayName: "Knicks", homeAbbreviation: "CHI", homeName: "Bulls", awayScore: 0, homeScore: 0, statusText: "8:30 PM", isLive: false)
]
),
MockLeagueSection(
id: "ncaam",
title: "NCAAM",
symbolName: "sportscourt.fill",
games: [
MockGame(id: "ncaa-1", awayAbbreviation: "ARIZ", awayName: "Arizona", homeAbbreviation: "BAY", homeName: "Baylor", awayScore: 71, homeScore: 73, statusText: "5:08 2nd", isLive: true),
MockGame(id: "ncaa-2", awayAbbreviation: "DUKE", awayName: "Duke", homeAbbreviation: "UNC", homeName: "North Carolina", awayScore: 81, homeScore: 77, statusText: "Final", isLive: false)
]
),
MockLeagueSection(
id: "mlb",
title: "MLB",
symbolName: "baseball.fill",
games: [
MockGame(id: "mlb-1", awayAbbreviation: "SF", awayName: "Giants", homeAbbreviation: "LAD", homeName: "Dodgers", awayScore: 3, homeScore: 5, statusText: "Top 7th", isLive: true),
MockGame(id: "mlb-2", awayAbbreviation: "NYY", awayName: "Yankees", homeAbbreviation: "BOS", homeName: "Red Sox", awayScore: 0, homeScore: 0, statusText: "9:10 PM", isLive: false),
MockGame(id: "mlb-3", awayAbbreviation: "SEA", awayName: "Mariners", homeAbbreviation: "HOU", homeName: "Astros", awayScore: 2, homeScore: 1, statusText: "Final", isLive: false)
]
),
MockLeagueSection(
id: "nhl",
title: "NHL",
symbolName: "hockey.puck.fill",
games: [
MockGame(id: "nhl-1", awayAbbreviation: "VGK", awayName: "Golden Knights", homeAbbreviation: "EDM", homeName: "Oilers", awayScore: 2, homeScore: 3, statusText: "3rd 04:41", isLive: true),
MockGame(id: "nhl-2", awayAbbreviation: "BOS", awayName: "Bruins", homeAbbreviation: "NYR", homeName: "Rangers", awayScore: 4, homeScore: 2, statusText: "Final", isLive: false)
]
),
MockLeagueSection(
id: "nfl",
title: "NFL",
symbolName: "football.fill",
games: [
MockGame(id: "nfl-1", awayAbbreviation: "BUF", awayName: "Bills", homeAbbreviation: "KC", homeName: "Chiefs", awayScore: 20, homeScore: 24, statusText: "Final", isLive: false),
MockGame(id: "nfl-2", awayAbbreviation: "PHI", awayName: "Eagles", homeAbbreviation: "DAL", homeName: "Cowboys", awayScore: 0, homeScore: 0, statusText: "Sun 4:25 PM", isLive: false)
]
)
]
}
private struct MockGame: Identifiable {
let id: String
let awayAbbreviation: String
let awayName: String
let homeAbbreviation: String
let homeName: String
let awayScore: Int
let homeScore: Int
let statusText: String
let isLive: Bool
}
#Preview {
NavigationStack {
ZStack {
Color(.systemBackground).ignoresSafeArea()
CustomStyleTest()
}
}
}
说明
- 固定头部是整张列表的单个Section头,我正在尝试根据滚动位置来更新它的内容。
- 每个“真实分区”在顶部有自己的头行(inListSectionHeader)以及顶部的几何标记。
- 在我的实际应用中,数据会周期性更新(实时比分),这会让滚动性能变差。
解决方案
Instead of using a PreferenceKey, it is usually much simpler just to update a state variable.
然而,在这里的场景中,跟踪可能根本就不必要。你现在有一个 LazyVStack,它包含固定头部,但它只包含一个巨大的 Section,在其中你显示了多个 MockLeagueSection。你随后自己承载着切换固定头部内容的所有工作。这也是你要跟踪顶部显示的是哪个联盟分区的原因。
An alternative way to implement would be to use a separate Section for each MockLeagueSection. This way, the pinned headers take care of themselves and you do not need to do any tracking at all.
下面的更新示例显示它已经在工作。你会发现它的代码量比之前少得多。几点说明:
- 当新头部向上滚动时,分区头现在会被挤走。这与之前头部被交换的方式不同,因此你需要考虑这样做是否可接受。
- 边框以红色显示,以便更清晰地看到和检查。我猜你是在尝试给滚动内容周围显示边框,顶部圆角固定,底部圆角随内容移动。要实现同样效果,边框的上半部分作为覆盖层覆盖在
ScrollView上,下半部分覆盖在LazyVStack上。这两个边框各自有自己的遮罩,用来控制可见部分。遮罩彼此重叠,形成连续边框的错觉。 - 你之前忽略了
ScrollView底部的安全区域边缘,因此裁剪形状也包含了安全区域内边距。这意味着需要为滚动内容添加底部填充,以便它能够滚动超过底部安全区域的插入。你使用了固定的100点填充。为了让底部填充保持在最小必要值,可以使用GeometryReader来测量底部安全区域的大小。
其实,归根结底也许仍然需要一些跟踪,原因是固定头部的样式。以前,只有滚动内容顶部的固定头部下面有一条分隔线,属于滚动内容的分区头部下面则没有分隔线。
为了以相同方式在固定头部下方显示分隔线,可以将分区头提取为一个单独的视图,并使用 .onGeometryChange 来检测它是否是 ScrollView 中的最上方头部。也就是说,当它的框架在 ScrollView 坐标系中的y 坐标接近0 时。检测到该头部当前是固定头部后,就可以显示分隔线。
使用 frame(in: .scrollView) 需要iOS 17。如果你需要支持iOS 16,可以改用带名的坐标空间。
struct CustomStyleTest: View {
@Environment(\.colorScheme) private var colorScheme
private let sections: [MockLeagueSection] = MockLeagueSection.sampleData
var body: some View {
GeometryReader { geo in
ScrollView(showsIndicators: false) {
customSectionsContent
.padding(.bottom, max(geo.safeAreaInsets.bottom, 20))
}
.clipShape(.rect(topLeadingRadius: 30, topTrailingRadius: 30))
.overlay {
containerShape
.strokeBorder(.red)
.mask(alignment: .top) {
Color.black.frame(height: 300)
}
}
.ignoresSafeArea(edges: .bottom)
.padding(.horizontal, 12)
.navigationTitle("Custom Style Test")
.navigationBarTitleDisplayMode(.inline)
}
}
private var customSectionsContent: some View {
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
ForEach(sections) { section in
Section {
customSectionRow(section: section)
} header: {
sectionHeader(section: section)
}
}
}
.background(containerFill, in: containerShape)
.overlay {
containerShape
.strokeBorder(.red)
.mask {
Color.black.padding(.top, 50)
}
}
}
private func customSectionRow(section: MockLeagueSection) -> some View {
VStack(spacing: 0) {
ForEach(Array(section.games.enumerated()), id: \.element.id) { gameIndex, game in
mockGameRow(game: game)
if gameIndex < section.games.count - 1 {
Divider()
.padding(.vertical, 2)
}
}
}
.padding()
}
private func sectionHeader(section: MockLeagueSection) -> some View {
LeagueSectionHeader(section: section)
.background { containerFill }
.background(.background)
}
private func mockGameRow(game: MockGame) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(game.statusText)
.font(.footnote.weight(.semibold))
.foregroundStyle(game.isLive ? .red : .secondary)
Spacer()
}
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 6) {
teamLine(abbreviation: game.awayAbbreviation, name: game.awayName)
teamLine(abbreviation: game.homeAbbreviation, name: game.homeName)
}
Spacer(minLength: 12)
VStack(alignment: .trailing, spacing: 6) {
Text("\(game.awayScore)")
.font(.title3.weight(.semibold))
.foregroundStyle(game.isLive ? .primary : .secondary)
Text("\(game.homeScore)")
.font(.title3.weight(.semibold))
.foregroundStyle(game.isLive ? .primary : .secondary)
}
}
}
.padding(.vertical, 10)
}
private func teamLine(abbreviation: String, name: String) -> some View {
HStack(spacing: 8) {
Text(abbreviation)
.font(.headline.weight(.semibold))
.foregroundStyle(.primary)
.frame(width: 60, alignment: .leading)
Text(name)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
private var containerShape: RoundedRectangle {
RoundedRectangle(cornerRadius: 30, style: .continuous)
}
private var containerFill: LinearGradient {
if colorScheme == .dark {
return LinearGradient(
colors: [
Color.white.opacity(0.07),
Color.white.opacity(0.07)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
} else {
return LinearGradient(
colors: [
Color.white.opacity(0.95),
Color(white: 0.965).opacity(0.92)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
}
}
struct LeagueSectionHeader: View {
fileprivate let section: MockLeagueSection
@State private var isTop = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Divider()
.padding(.bottom, 4)
sectionHeaderLabel
.padding()
}
.overlay(alignment: .bottom) {
if isTop {
Divider()
}
}
.animation(.easeInOut, value: isTop)
.onGeometryChange(for: Bool.self) { geo in
abs(geo.frame(in: .scrollView).minY) < 1
} action: { isTopOfScrollView in
isTop = isTopOfScrollView
}
}
private var sectionHeaderLabel: some View {
HStack(spacing: 8) {
Image(systemName: section.symbolName)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Text(section.title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Image(systemName: "chevron.right")
.font(.caption.weight(.bold))
.foregroundStyle(.secondary)
}
}
}
// + MockLeagueSection, MockGame (as before)
// - CustomStyleTestSectionMarker, CustomStyleTestSectionMarkerPreferenceKey (obsolete)
