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:
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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$ --"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user