SwiftUI的滚动视图:GeometryReader与 PreferenceKey的问题

移动开发 2026-07-12

我正在打造一个SwiftUI风格的“记分牌”界面:一个ScrollView,顶部只有一个固定头部。随着滚动,固定头部应更新显示当前活动的分区(NBA/NHL等),类似于一个粘性“当前分类”标签。

我目前的做法是:

  • 每个分区在顶部插入一个GeometryReader
  • 每个GeometryReader将一个标记(id、minY)写入一个PreferenceKey
  • onPreferenceChange会在每帧读取所有标记,基于阈值找到活动的分区,并更新一个 @State的 activeLeagueID
  • 固定头部读取activeLeagueID并更新其标签

用少量的模拟数据时,这个方法可以工作,但在真实数据下(分区更多、行数更多、实时更新)我看到:

  • 滚动过程中会出现卡顿,有时滚动后会把ScrollView向上或向下微调(我在想这是来自PreferenceKey的副作用)
  • 偶尔会出现运行时警告:

  • Bound preference tried to update multiple times per frame

问题

是否有一个 更好/更高效的方式来实现这个“滚动时的活动分区”行为?

具体来说:

  1. 是否有GeometryReader + PreferenceKey的替代方案,能够避免逐帧的偏好更新频繁churn?
  2. 如果这种做法是“正确的”,如何降低工作量(对标记进行排序、减少频繁的状态更新等),以防止“每帧多次更新”的警告?

完整示例

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)

Animation

站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章