fix: stabilize view tree for zoom transition recovery
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 3s

ROOT CAUSE: @State TripsViewModel inside TripsHomeView was recreated
during navigation transitions. Each recreation set isLoading=true,
flipping the Group conditional, destroying the ScrollView + namespace.
The zoom transition's matchedTransitionSource evaporated.

FIXES (matching Apple's Wishlist pattern):
1. ViewModel owned by MainTabView (like ReaderVM) — survives
   view recreation. Pre-loaded on app launch.
2. Loading state rendered INSIDE ScrollView — no conditional
   Group wrapper that swaps the entire view tree.
3. Single @Namespace in TripsHomeView, passed to both
   UpcomingTripsPageView and PastTripsSection.
4. Zoom transition restored on all NavigationLinks.

Why Apple's sample works: they use @Environment(DataSource.self)
which is app-level stable. Our equivalent: @State at MainTabView.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-04 14:43:46 -05:00
parent 864ca679ce
commit 583138fbe2
4 changed files with 46 additions and 30 deletions

View File

@@ -28,6 +28,7 @@ struct MainTabView: View {
@State private var showAssistant = false
@State private var confettiTrigger = 0
@State private var readerVM = ReaderViewModel()
@State private var tripsVM = TripsViewModel()
@State private var isAutoScrolling = false
@State private var scrollSpeed: Double = 1.5
@State private var speedLevel = 0 // 0=slow, 1=med, 2=fast
@@ -82,7 +83,7 @@ struct MainTabView: View {
}
Tab("Trips", systemImage: "airplane", value: 4) {
TripsHomeView()
TripsHomeView(vm: tripsVM)
}
// Action button separated circle on trailing side of tab bar
@@ -137,6 +138,10 @@ struct MainTabView: View {
}
}
.task {
guard showReader else { return }
// Pre-load trips for all users
await tripsVM.loadTrips()
guard showReader else { return }
let renderer = ArticleRenderer.shared
renderer.attachToWindow()

View File

@@ -4,6 +4,7 @@ import SwiftUI
/// Adapted from Apple's Wishlist TripCollectionView.
struct PastTripsSection: View {
let trips: [Trip]
var namespace: Namespace.ID
var body: some View {
if !trips.isEmpty {
@@ -13,8 +14,11 @@ struct PastTripsSection: View {
ForEach(trips) { trip in
NavigationLink {
TripPlaceholderView(trip: trip)
.navigationTransition(
.zoom(sourceID: trip.id, in: namespace))
} label: {
TripCard(trip: trip, size: .compact)
.matchedTransitionSource(id: trip.id, in: namespace)
.contentShape(.rect)
}
.buttonStyle(.plain)

View File

@@ -2,41 +2,46 @@ import SwiftUI
/// The main Trips home screen inspired by Apple's Wishlist sample.
///
/// Layout:
/// 1. Hero section: upcoming trips as full-width paged cards
/// 2. Secondary section: past trips in horizontal compact cards
///
/// Adapted from WishlistView structure: NavigationStack + ScrollView + sections.
/// ViewModel owned by MainTabView (stable across navigation transitions).
/// Always renders the ScrollView no conditional loading/content flip
/// that would destroy the namespace during transitions.
struct TripsHomeView: View {
@State private var vm = TripsViewModel()
@Bindable var vm: TripsViewModel
@Namespace private var namespace
var body: some View {
NavigationStack {
Group {
ScrollView {
if vm.isLoading && vm.trips.isEmpty {
LoadingView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
VStack(alignment: .leading, spacing: 10) {
// Hero: upcoming trips (paged, full-width)
UpcomingTripsPageView(trips: vm.upcomingTrips)
.padding(.bottom, 20)
// Secondary: past trips (horizontal scroll, compact cards)
PastTripsSection(trips: vm.pastTrips)
}
// Loading inside the ScrollView keeps view tree stable
VStack {
Spacer(minLength: 200)
ProgressView()
.controlSize(.regular)
Text("Loading trips...")
.font(.caption)
.foregroundStyle(Color.textTertiary)
.padding(.top, 8)
Spacer(minLength: 200)
}
.contentMargins(.bottom, 30, for: .scrollContent)
.ignoresSafeArea(edges: .top)
.refreshable {
await vm.refresh()
.frame(maxWidth: .infinity)
} else {
VStack(alignment: .leading, spacing: 10) {
UpcomingTripsPageView(trips: vm.upcomingTrips, namespace: namespace)
.padding(.bottom, 20)
PastTripsSection(trips: vm.pastTrips, namespace: namespace)
}
}
}
.contentMargins(.bottom, 30, for: .scrollContent)
.ignoresSafeArea(edges: .top)
.refreshable {
await vm.refresh()
}
.background(Color.canvas)
.toolbar {
// Custom expanded title Wishlist pattern
ToolbarItem(placement: .title) {
HStack {
Text("Trips")
@@ -48,7 +53,6 @@ struct TripsHomeView: View {
}
}
// Plan Trip button glass prominent style
ToolbarItem(placement: .topBarTrailing) {
Button {
// Phase 2: present plan trip sheet
@@ -63,8 +67,5 @@ struct TripsHomeView: View {
.toolbarTitleDisplayMode(.inline)
.toolbarRole(.editor)
}
.task {
await vm.loadTrips()
}
}
}

View File

@@ -2,12 +2,15 @@ import SwiftUI
/// Displays upcoming trips in a paged TabView the hero section.
/// Adapted from Apple's Wishlist RecentTripsPageView.
///
/// Namespace is passed from the parent (TripsHomeView) to ensure
/// stability across navigation transitions.
struct UpcomingTripsPageView: View {
let trips: [Trip]
var namespace: Namespace.ID
var body: some View {
if trips.isEmpty {
// No upcoming trips show a prompt
VStack(spacing: 16) {
Image(systemName: "airplane.departure")
.font(.system(size: 44))
@@ -26,6 +29,8 @@ struct UpcomingTripsPageView: View {
ForEach(trips) { trip in
NavigationLink {
TripPlaceholderView(trip: trip)
.navigationTransition(
.zoom(sourceID: trip.id, in: namespace))
} label: {
TripImageView(url: trip.imageURL, fallbackName: trip.name)
.overlay(alignment: .bottomLeading) {
@@ -48,6 +53,7 @@ struct UpcomingTripsPageView: View {
.padding(.horizontal)
.padding(.bottom, 54)
}
.matchedTransitionSource(id: trip.id, in: namespace)
}
.buttonStyle(.plain)
}