SwiftUI ScrollView: GeometryReader + PreferenceKey Issues

2 weeks ago 15
ARTICLE AD BOX

I’m building a SwiftUI “scoreboard” style UI: a ScrollView with a single pinned header at the top. As you scroll, the pinned header should update to show the currently active section (NBA/NHL/etc), similar to a sticky “current category” label.

My current approach is:

Each section inserts a GeometryReader at its top

Each GeometryReader writes a marker (id, minY) into a PreferenceKey

onPreferenceChange reads all markers each frame, finds the active section based on a threshold, and updates a @State activeLeagueID

The pinned header reads activeLeagueID and updates its label

This works with small mock data, but with real data (more sections, more rows, live updating) I see:

Scrolling buggy, and sometimes shifts the scrollview up or down after scroll (Im thinking its a side effect from PreferenceKey)

Occasionally the runtime warning:

Bound preference <PreferenceKey> tried to update multiple times per frame

Question

Is there a better / more performant way to implement this “active section while scrolling” behavior?

Specifically:

Is there an alternative to GeometryReader + PreferenceKey that avoids per-frame preference churn?

If this approach is “correct”, how can I reduce the work (sorting markers, frequent state updates, etc.) and prevent the “multiple updates per frame” warning?

Full Example

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() } } }

Notes

The pinned header is a single Section header for the whole list, and I’m trying to update its content based on scroll position.

Each “real section” has its own header row (inListSectionHeader) plus the geometry marker at the top.

In my real app, data updates periodically (live scores), which makes the scroll performance worse.

Read Entire Article