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
- Bound preference
Question
Is there a better / more performant SwiftUI-native 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?
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.