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

View File

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

View File

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

View File

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