fix(store): replace ZStack overlay sticky approach with native pinnedViews + reliable scroll tracking

- Use LazyVStack(pinnedViews: [.sectionHeaders]) for native section header stickiness
- Move ScrollOffsetReader outside LazyVStack (sibling) to prevent lazy-unload breaking scroll tracking
- Add baseline capture to ScrollOffsetReader to correctly normalize offset regardless of ScrollView content insets
- Apply safeAreaTop padding to categoryTabs only when pinned (isCategoryTabsPinned), preventing oversized header before scroll
- Remove complex ZStack dual-tabs overlay, stickyProgress logic, and categoryTabsStickyProgress
- categoryTabs now accepts isPinned flag with easeInOut(0.15s) animation on pin transition

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel Arantes Loverde
2026-06-03 14:26:23 -03:00
parent b61f59c253
commit 69dd67b8a5
4 changed files with 37 additions and 65 deletions

View File

@@ -22,6 +22,7 @@ struct ScrollOffsetPreferenceKey: PreferenceKey {
struct ScrollOffsetReader: View { struct ScrollOffsetReader: View {
@Binding var offsetY: CGFloat @Binding var offsetY: CGFloat
@State private var baseline: CGFloat? = nil
var body: some View { var body: some View {
Color.clear Color.clear
@@ -35,9 +36,10 @@ struct ScrollOffsetReader: View {
} }
) )
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { minY in .onPreferenceChange(ScrollOffsetPreferenceKey.self) { minY in
let normalizedOffset = max(0, -minY) if baseline == nil { baseline = minY }
if abs(offsetY - normalizedOffset) > 0.5 { let offset = max(0, (baseline ?? 0) - minY)
offsetY = normalizedOffset if abs(offsetY - offset) > 0.5 {
offsetY = offset
} }
} }
} }

View File

@@ -195,10 +195,15 @@ extension StoreDetailView {
} }
} }
func categoryTabs(proxy: ScrollViewProxy, topExtension: CGFloat = 0, stickyProgress: CGFloat = 0) -> some View { func categoryTabs(proxy: ScrollViewProxy, isPinned: Bool = false) -> some View {
let chipPaddingH: CGFloat = 10 + 6 * stickyProgress let safeTop: CGFloat = {
let chipPaddingV: CGFloat = 4 + 5 * stickyProgress guard isPinned else { return 0 }
let containerPaddingV: CGFloat = 2 + 8 * stickyProgress #if canImport(UIKit)
return UIDevice.appSafeAreaTop
#else
return 0
#endif
}()
return ScrollView(.horizontal, showsIndicators: false) { return ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) { HStack(spacing: 10) {
@@ -217,8 +222,8 @@ extension StoreDetailView {
Text(category.name) Text(category.name)
.font(AppTypography.heading3) .font(AppTypography.heading3)
.foregroundStyle(active ? AppColors.textInverse : AppColors.textMuted) .foregroundStyle(active ? AppColors.textInverse : AppColors.textMuted)
.padding(.horizontal, chipPaddingH) .padding(.horizontal, 16)
.padding(.vertical, chipPaddingV) .padding(.vertical, 9)
.background(active ? AppColors.primary : AppColors.surface) .background(active ? AppColors.primary : AppColors.surface)
.clipShape(Capsule()) .clipShape(Capsule())
} }
@@ -226,16 +231,11 @@ extension StoreDetailView {
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, containerPaddingV) .padding(.vertical, 10)
} }
.padding(.top, safeTop)
.background(AppColors.backgroundLight) .background(AppColors.backgroundLight)
.overlay(alignment: .top) { .animation(.easeInOut(duration: 0.15), value: isPinned)
AppColors.backgroundLight
.frame(height: topExtension)
.frame(maxWidth: .infinity)
.offset(y: -topExtension)
.allowsHitTesting(false)
}
} }
func productCard(_ item: StoreCatalogListItem, in category: StoreCatalogCategory) -> some View { func productCard(_ item: StoreCatalogListItem, in category: StoreCatalogCategory) -> some View {

View File

@@ -51,8 +51,8 @@ extension StoreDetailView {
} }
var deliveryValueLabel: String { var deliveryValueLabel: String {
if let minOrder = info?.minOrder { if let fee = storeDeliveryFee {
return formatCurrency(minOrder) return formatCurrency(fee)
} }
return "R$ --" return "R$ --"
} }

View File

@@ -34,15 +34,10 @@ struct StoreDetailView: View {
@State var didLoad = false @State var didLoad = false
@State var categoryHeaderOffsets: [String: CGFloat] = [:] @State var categoryHeaderOffsets: [String: CGFloat] = [:]
@State var isProgrammaticCategoryScroll = false @State var isProgrammaticCategoryScroll = false
@State var scrollOffset: CGFloat = 0
var safeAreaTop: CGFloat {
#if canImport(UIKit)
return UIDevice.appSafeAreaTop
#else
return 0.0
#endif
}
@State var isFavoriteRequestInFlight = false @State var isFavoriteRequestInFlight = false
@State var scrollOffset: CGFloat = 0
var isCategoryTabsPinned: Bool { scrollOffset >= topSectionHeight }
let cardTopInset: CGFloat = 180 let cardTopInset: CGFloat = 180
let summaryCardBaseHeight: CGFloat = 212 let summaryCardBaseHeight: CGFloat = 212
@@ -52,48 +47,23 @@ struct StoreDetailView: View {
var body: some View { var body: some View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
let stickyProgress = categoryTabsStickyProgress(safeTop: safeAreaTop) ScrollView(showsIndicators: false) {
ScrollOffsetReader(offsetY: $scrollOffset)
ZStack(alignment: .top) { LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
AppColors.backgroundLight.ignoresSafeArea() topSection
Section {
ScrollView(showsIndicators: false) { sectionedProducts
LazyVStack(spacing: 0) { } header: {
topSection categoryTabs(proxy: proxy, isPinned: isCategoryTabsPinned)
Section {
sectionedProducts
} header: {
categoryTabs(proxy: proxy, topExtension: 0, stickyProgress: 1)
.opacity(Double(1 - stickyProgress))
}
}
.background(
GeometryReader { geometry in
Color.clear.preference(
key: ScrollOffsetPreferenceKey.self,
value: geometry.frame(in: .named(StoreDetailScrollCoordinateSpace.name)).minY
)
}
)
}
.coordinateSpace(name: StoreDetailScrollCoordinateSpace.name)
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { minY in
let normalizedOffset = max(0, -minY)
if abs(scrollOffset - normalizedOffset) > 0.5 {
scrollOffset = normalizedOffset
} }
} }
.refreshable { }
await loadStoreData(forceRefresh: true) .coordinateSpace(name: StoreDetailScrollCoordinateSpace.name)
} .refreshable {
.ignoresSafeArea(edges: .top) await loadStoreData(forceRefresh: true)
categoryTabs(proxy: proxy, topExtension: stickyProgress * safeAreaTop, stickyProgress: 1)
.opacity(Double(stickyProgress))
.allowsHitTesting(stickyProgress > 0.1)
.offset(y: safeAreaTop)
} }
.ignoresSafeArea(edges: .top) .ignoresSafeArea(edges: .top)
.background(AppColors.backgroundLight)
.saturation(isStoreOpen ? 1 : 0) .saturation(isStoreOpen ? 1 : 0)
} }
.navigationBarBackButtonHidden(true) .navigationBarBackButtonHidden(true)