Colapse fix

This commit is contained in:
Daniel Arantes Loverde
2026-02-09 14:41:18 -03:00
parent 54686397b9
commit 3176b67914
8 changed files with 683 additions and 127 deletions

View File

@@ -1,3 +1,4 @@
import Foundation
import SwiftUI
#if os(iOS)
import UIKit
@@ -6,52 +7,55 @@ import UIKit
struct HomeView: View {
@Binding var appState: AppState
@State var searchText = ""
@State var selectedCategory = "Stores"
@State var selectedCategory = "all"
@State var categories: [CategoryModel] = [
.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle")
]
@State var scrollOffset: CGFloat = 0
@State var collapseBaseOffset: CGFloat = 0
@State var collapseDragStartOffset: CGFloat? = nil
@State var hasRequestedLocation = false
@State var isLoadingStores = false
@State var storesError: String? = nil
@State var stores: [StoreSummary] = []
private let categories: [CategoryModel] = [
.init(title: "Stores", systemIcon: "storefront"),
.init(title: "Asian", systemIcon: "fork.knife"),
.init(title: "Breakfast", systemIcon: "sun.max"),
.init(title: "Pizza", systemIcon: "takeoutbag.and.cup.and.straw"),
.init(title: "Dessert", systemIcon: "cup.and.saucer")
]
private let specials: [SpecialOfferCardModel] = [
.init(title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]),
.init(title: "Enjoy spicy day", subtitle: "Delivery in 20 min", colors: [Color(hex: "#F04B3E"), Color(hex: "#FF8C42")])
.init(id: "ddddd", title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]),
.init(id: "eeeee", title: "Enjoy spicy day", subtitle: "Delivery in 20 min", colors: [Color(hex: "#F04B3E"), Color(hex: "#FF8C42")])
]
private let headerExpandedHeight: CGFloat = 260
private let headerExpandedHeight: CGFloat = 240
private let headerCollapsedHeight: CGFloat = 120
private let contentTopPadding: CGFloat = 296
private let contentTopSpacing: CGFloat = 18
private let contentBottomSpacing: CGFloat = 120
var body: some View {
let collapseProgress = clamp(value: -scrollOffset / (headerExpandedHeight - headerCollapsedHeight), lower: 0, upper: 1)
let collapseProgress = clamp(value: scrollOffset / (headerExpandedHeight - headerCollapsedHeight), lower: 0, upper: 1)
let headerHeight = headerExpandedHeight - (headerExpandedHeight - headerCollapsedHeight) * collapseProgress
let headerPadding = headerExpandedHeight - (headerExpandedHeight - headerCollapsedHeight) * collapseProgress
return ZStack(alignment: .top) {
#if os(iOS)
TrackableScrollView(onOffsetChange: { value in
scrollOffset = min(0, -value)
}) {
ScrollView(showsIndicators: false) {
contentStack
.padding(.top, headerPadding - 10)
.padding(.bottom, 24)
.padding(.top, headerExpandedHeight + contentTopSpacing)
.padding(.bottom, contentBottomSpacing)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
#else
ScrollView {
contentStack
.padding(.top, headerPadding + 16)
.padding(.bottom, 24)
}
#endif
.background(scrollOffsetObserver)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
let range = headerExpandedHeight - headerCollapsedHeight
if collapseDragStartOffset == nil {
collapseDragStartOffset = collapseBaseOffset
}
let start = collapseDragStartOffset ?? collapseBaseOffset
let candidate = start - value.translation.height
scrollOffset = clamp(value: candidate, lower: 0, upper: range)
}
.onEnded { _ in
collapseBaseOffset = scrollOffset
collapseDragStartOffset = nil
}
)
header(collapseProgress: collapseProgress, height: headerHeight)
.frame(maxWidth: .infinity, alignment: .top)
@@ -63,16 +67,15 @@ struct HomeView: View {
if hasRequestedLocation == false {
hasRequestedLocation = true
Task {
await bootstrapStoresFlow()
await bootstrapStoresFlow(refreshCategories: true)
}
}
collapseBaseOffset = scrollOffset
}
}
private var contentStack: some View {
VStack(spacing: 24) {
scrollOffsetMarker
categoriesSection
section(title: "Featured") {
@@ -115,7 +118,10 @@ struct HomeView: View {
.foregroundStyle(AppColors.textMuted)
Button("Tentar novamente") {
Task {
await bootstrapStoresFlow(forceLocationRefresh: true)
await bootstrapStoresFlow(
forceLocationRefresh: true,
category: selectedCategory == "all" ? nil : selectedCategory
)
}
}
.font(AppTypography.heading3)
@@ -194,8 +200,8 @@ struct HomeView: View {
.opacity(topRowOpacity)
.offset(y: collapseProgress * -12)
Text("O que vai querer pedir hoje?")
.font(AppTypography.heading1)
Text("O que vai querer \npedir hoje?")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textInverse)
.opacity(titleOpacity)
.offset(y: collapseProgress * -20)
@@ -203,23 +209,13 @@ struct HomeView: View {
SearchBar(placeholder: "Search menu, restaurant or craving", text: $searchText) {
appState.activeModal = .filters
}
.offset(y: collapseProgress * -140)
.offset(y: collapseProgress * -120)
}
.padding(.horizontal, 20)
.padding(.top, 18)
}
}
@ViewBuilder
private var scrollOffsetMarker: some View {
#if os(iOS)
EmptyView()
#else
ScrollOffsetReader()
.offset(y: -contentTopPadding)
#endif
}
private func section<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 16) {
Text(title)
@@ -244,10 +240,14 @@ struct HomeView: View {
CategoryChip(
title: category.title,
systemIcon: category.systemIcon,
isActive: category.title == selectedCategory
isActive: category.id == selectedCategory
)
.onTapGesture {
selectedCategory = category.title
guard category.id != selectedCategory else { return }
selectedCategory = category.id
Task {
await bootstrapStoresFlow(category: category.id == "all" ? nil : category.id)
}
}
}
}
@@ -285,6 +285,7 @@ struct HomeView: View {
private func mapStoreToCard(_ store: StoreSummary) -> FeaturedStoreCardModel {
FeaturedStoreCardModel(
id: store.id,
name: store.name,
rating: store.rating ?? 0,
reviews: "0",
@@ -305,7 +306,11 @@ struct HomeView: View {
}
@MainActor
private func bootstrapStoresFlow(forceLocationRefresh: Bool = false) async {
private func bootstrapStoresFlow(
forceLocationRefresh: Bool = false,
category: String? = nil,
refreshCategories: Bool = false
) async {
isLoadingStores = true
storesError = nil
@@ -319,14 +324,21 @@ struct HomeView: View {
}
do {
let response = try await ApiService().listStores(lat: coordinate.0, lng: coordinate.1)
let response = try await ApiService().listStores(lat: coordinate.0, lng: coordinate.1, category: category)
isLoadingStores = false
if response.error {
stores = []
storesError = response.message ?? "Não foi possível carregar os estabelecimentos."
return
}
stores = response.result ?? []
let results = response.result ?? []
stores = results
if refreshCategories {
categories = buildCategories(from: results)
if categories.contains(where: { $0.id == selectedCategory }) == false {
selectedCategory = "all"
}
}
storesError = nil
} catch {
isLoadingStores = false
@@ -335,6 +347,32 @@ struct HomeView: View {
}
}
private func buildCategories(from stores: [StoreSummary]) -> [CategoryModel] {
var unique: [CategoryModel] = [
.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle")
]
var seen = Set<String>()
for store in stores {
let raw = (store.category ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if raw.isEmpty { continue }
let dedupe = raw.lowercased()
if seen.contains(dedupe) { continue }
seen.insert(dedupe)
unique.append(.init(id: raw, title: raw, systemIcon: categoryIcon(for: raw)))
}
return unique
}
private func categoryIcon(for category: String) -> String {
let value = category.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current).lowercased()
if value.contains("pizza") { return "takeoutbag.and.cup.and.straw" }
if value.contains("lanche") || value.contains("hamburg") || value.contains("burger") { return "fork.knife" }
if value.contains("cafe") || value.contains("breakfast") { return "sun.max" }
if value.contains("doce") || value.contains("sobremesa") || value.contains("dessert") { return "cup.and.saucer" }
return "storefront"
}
@MainActor
private func resolveCoordinates(forceRefresh: Bool) async -> (Double, Double)? {
if !forceRefresh {
@@ -382,10 +420,25 @@ struct HomeView: View {
}
return "Não foi possível carregar os estabelecimentos."
}
@ViewBuilder
private var scrollOffsetObserver: some View {
#if os(iOS)
ScrollOffsetObserver { y in
// Use only upward displacement for collapse and ignore top bounce.
let normalized = max(0, y)
scrollOffset = normalized
collapseBaseOffset = normalized
}
.frame(width: 0, height: 0)
#else
EmptyView()
#endif
}
}
struct CategoryModel: Identifiable {
let id = UUID()
let id: String
let title: String
let systemIcon: String
}
@@ -434,80 +487,78 @@ struct SearchBar: View {
}
}
struct ScrollOffsetKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
struct ScrollOffsetReader: View {
var body: some View {
GeometryReader { proxy in
Color.clear
.preference(key: ScrollOffsetKey.self, value: proxy.frame(in: .named("scroll")).minY)
}
.frame(height: 1)
}
}
func clamp(value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat {
min(max(value, lower), upper)
}
#if os(iOS)
@MainActor
struct TrackableScrollView<Content: View>: UIViewRepresentable {
private struct ScrollOffsetObserver: UIViewRepresentable {
let onOffsetChange: (CGFloat) -> Void
let content: Content
init(onOffsetChange: @escaping (CGFloat) -> Void, @ViewBuilder content: () -> Content) {
self.onOffsetChange = onOffsetChange
self.content = content()
func makeUIView(context: Context) -> ScrollOffsetProbeView {
let view = ScrollOffsetProbeView()
view.onOffsetChange = onOffsetChange
return view
}
func makeCoordinator() -> Coordinator {
Coordinator(onOffsetChange: onOffsetChange)
func updateUIView(_ uiView: ScrollOffsetProbeView, context: Context) {
uiView.onOffsetChange = onOffsetChange
uiView.attachIfNeeded()
}
}
@MainActor
private final class ScrollOffsetProbeView: UIView {
var onOffsetChange: (CGFloat) -> Void = { _ in }
private weak var observedScrollView: UIScrollView?
private var observation: NSKeyValueObservation?
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isUserInteractionEnabled = false
}
func makeUIView(context: Context) -> UIScrollView {
let scrollView = UIScrollView()
scrollView.showsVerticalScrollIndicator = false
scrollView.alwaysBounceVertical = true
let host = UIHostingController(rootView: content)
host.view.translatesAutoresizingMaskIntoConstraints = false
host.view.backgroundColor = .clear
scrollView.addSubview(host.view)
NSLayoutConstraint.activate([
host.view.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
host.view.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
host.view.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
host.view.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
host.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor)
])
context.coordinator.hostingController = host
scrollView.delegate = context.coordinator
return scrollView
required init?(coder: NSCoder) {
super.init(coder: coder)
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
context.coordinator.hostingController?.rootView = content
deinit {
observation?.invalidate()
}
final class Coordinator: NSObject, UIScrollViewDelegate {
var hostingController: UIHostingController<Content>?
let onOffsetChange: (CGFloat) -> Void
override func didMoveToSuperview() {
super.didMoveToSuperview()
attachIfNeeded()
}
init(onOffsetChange: @escaping (CGFloat) -> Void) {
self.onOffsetChange = onOffsetChange
override func didMoveToWindow() {
super.didMoveToWindow()
attachIfNeeded()
}
func attachIfNeeded() {
guard let scrollView = findEnclosingScrollView() else { return }
guard scrollView !== observedScrollView else { return }
observation?.invalidate()
observedScrollView = scrollView
observation = scrollView.observe(\.contentOffset, options: [.initial, .new]) { [weak self] sv, _ in
self?.onOffsetChange(sv.contentOffset.y)
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
onOffsetChange(scrollView.contentOffset.y)
private func findEnclosingScrollView() -> UIScrollView? {
var view: UIView? = self
while let current = view {
if let scrollView = current as? UIScrollView {
return scrollView
}
view = current.superview
}
return nil
}
}
#endif