Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import SwiftUI
import struct Yosemite.Booking

struct BookingListContainerView: View {
@ObservedObject private var viewModel: BookingListContainerViewModel

init(viewModel: BookingListContainerViewModel) {
self.viewModel = viewModel
}

var body: some View {
NavigationStack {
ZStack {
ForEach(BookingListTab.allCases, id: \.rawValue) { tab in
BookingListView(viewModel: viewModel.listViewModel(for: tab)) {
headerView
}
.opacity(viewModel.selectedTab == tab ? 1 : 0)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please describe the reasoning behind this approach of

  • injecting a header (tabs + filter bar) to each list view. When switching tabs, the header also has fade-in animation.
  • switching lists by toggling the opacity.

How about keeping the header above switchable list views and using a TabView:

VStack {
    headerView
    TabView(selection: $viewModel.selectedTab) {
        ForEach(BookingListTab.allCases, id: \.rawValue) { tab in
            BookingListView(viewModel: viewModel.listViewModel(for: tab)) {
            }
            .tag(tab)
        }
    }
    .tabViewStyle(.page(indexDisplayMode: .never))
}

We have other places in the app where the TabView is used.

Copy link
Contributor Author

@itsmeichigo itsmeichigo Oct 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

injecting a header (tabs + filter bar) to each list view. When switching tabs, the header also has fade-in animation.

Hey - this was a workaround for the refresh control in the booking list. When the list is added to a navigation view with large title, pulling down the list will also pull down the big title while the header (tab bar and filter bar) stays fixed at the top, which looks bad. Injecting the header to a section inside the scroll view and ask the LazyVStack to pin the section header helped pulling the header down as well.

switching lists by toggling the opacity.

I wanted to keep the states of the lists instead of re-rendering and loading bookings again upon switching tabs.

How about keeping the header above switchable list views and using a TabView

Thanks for the great idea! I didn't think about using TabView just for the content, and TIL about hiding the index view. Using the TabView fixes both the problems above for me: managing the tab views and keeping the refresh control under the header view, instead of above the large navigation title:

Simulator Screenshot - iPhone 17 - 2025-10-02 at 14 21 24

I updated the BookingListView to remove the now redundant workarounds for the header and refresh control. I also added a check to avoid loading the booking list again if it's not empty.

Please take another look when you can 🙏

.navigationTitle(Localization.viewTitle)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button {
// TODO
} label: {
Image(systemName: "magnifyingglass")
}
}
}
}
}
}

private extension BookingListContainerView {
var headerView: some View {
VStack(spacing: 0) {
topTabView
Divider()
HStack {
Button {
// TODO
} label: {
Text(Localization.sortBy)
.font(.body)
.foregroundStyle(Color.accentColor)
}
Spacer()
Button {
// TODO
} label: {
Text(Localization.filter)
.font(.body)
.foregroundStyle(Color.accentColor)
}
}
.padding()
.background(Color(.listForeground(modal: false)))
Divider()
}
}

var topTabView: some View {
GeometryReader { geometry in
HStack {
ForEach(BookingListTab.allCases, id: \.rawValue) { tab in
Button {
withAnimation(.easeInOut(duration: 0.3)) {
viewModel.selectedTab = tab
}
} label: {
Text(tab.title)
.font(.subheadline)
.foregroundStyle(viewModel.selectedTab == tab ? Color.accentColor : Color.primary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
}
.overlay(alignment: .bottom) {
Color.accentColor
.frame(width: geometry.size.width / CGFloat(BookingListTab.allCases.count),
height: Layout.selectedTabIndicatorHeight)
.offset(x: tabIndicatorOffset(containerWidth: geometry.size.width,
tabCount: BookingListTab.allCases.count,
selectedIndex: viewModel.selectedTab.rawValue))
.animation(.easeInOut(duration: 0.3), value: viewModel.selectedTab.rawValue)
}
}
.frame(height: Layout.topTabBarHeight)
.background(Color(.listForeground(modal: false)))
}

/// SwiftUI's coordinate system places (0,0) at the center of the container, so we need to:
/// 1. Calculate how far the selected tab is from the left edge
/// 2. Adjust for the center-based coordinate system
/// 3. Center the indicator within the selected tab
///
func tabIndicatorOffset(containerWidth: CGFloat, tabCount: Int, selectedIndex: Int) -> CGFloat {
let tabWidth = containerWidth / CGFloat(tabCount)
let distanceFromLeftEdge = tabWidth * CGFloat(selectedIndex)
let adjustmentForCenterOrigin = containerWidth / 2
let centerWithinTab = tabWidth / 2

return distanceFromLeftEdge - adjustmentForCenterOrigin + centerWithinTab
}
}
private extension BookingListContainerView {
enum Layout {
static let topTabBarHeight: CGFloat = 44
static let selectedTabIndicatorHeight: CGFloat = 3.0
}

enum Localization {
static let viewTitle = NSLocalizedString(
"bookingListView.view.title",
value: "Bookings",
comment: "Title of the booking list view"
)
static let sortBy = NSLocalizedString(
"bookingListView.sortBy",
value: "Sort by",
comment: "Button to select the order of the booking list"
)
static let filter = NSLocalizedString(
"bookingListView.filter",
value: "Filter",
comment: "Button to filter the booking list"
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import Foundation

/// View model for `BookingListContainerView`
final class BookingListContainerViewModel: ObservableObject {
private let todayListViewModel: BookingListViewModel
private let upcomingListViewModel: BookingListViewModel
private let allListViewModel: BookingListViewModel

@Published var selectedTab: BookingListTab = .today

init(siteID: Int64) {
self.todayListViewModel = BookingListViewModel(siteID: siteID, type: .today)
self.upcomingListViewModel = BookingListViewModel(siteID: siteID, type: .upcoming)
self.allListViewModel = BookingListViewModel(siteID: siteID, type: .all)
}

func listViewModel(for tab: BookingListTab) -> BookingListViewModel {
switch tab {
case .today:
todayListViewModel
case .upcoming:
upcomingListViewModel
case .all:
allListViewModel
}
}
}

enum BookingListTab: Int, CaseIterable {
case today
case upcoming
case all

var title: String {
switch self {
case .today: Localization.today
case .upcoming: Localization.upcoming
case .all: Localization.all
}
}

private enum Localization {
static let today = NSLocalizedString(
"bookingListView.today",
value: "Today",
comment: "Tab title for today's bookings"
)
static let upcoming = NSLocalizedString(
"bookingListView.upcoming",
value: "Upcoming",
comment: "Tab title for upcoming bookings"
)
static let all = NSLocalizedString(
"bookingListView.all",
value: "All",
comment: "Tab title for all bookings"
)
}
}
Loading