SwiftUI ScrollView: GeometryReader + PreferenceKey Issues
23:27 27 Feb 2026

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 tried to update multiple times per frame

Question

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

Specifically:

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

  2. 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?

  3. Are there recommended APIs in newer SwiftUI (iOS 17/18+) for tracking which view is currently visible / top-most in a scroll container?

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.

swift swiftui scrollview