From 583138fbe2fa161b1ba4089920dfcb24f075fe65 Mon Sep 17 00:00:00 2001 From: Yusuf Suleman Date: Sat, 4 Apr 2026 14:43:46 -0500 Subject: [PATCH] fix: stabilize view tree for zoom transition recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ios/Platform/Platform/ContentView.swift | 7 ++- .../Trips/Views/PastTripsSection.swift | 4 ++ .../Features/Trips/Views/TripsHomeView.swift | 57 ++++++++++--------- .../Trips/Views/UpcomingTripsPageView.swift | 8 ++- 4 files changed, 46 insertions(+), 30 deletions(-) diff --git a/ios/Platform/Platform/ContentView.swift b/ios/Platform/Platform/ContentView.swift index f9884ed..6e1ac1a 100644 --- a/ios/Platform/Platform/ContentView.swift +++ b/ios/Platform/Platform/ContentView.swift @@ -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() diff --git a/ios/Platform/Platform/Features/Trips/Views/PastTripsSection.swift b/ios/Platform/Platform/Features/Trips/Views/PastTripsSection.swift index 0776a1a..b907e88 100644 --- a/ios/Platform/Platform/Features/Trips/Views/PastTripsSection.swift +++ b/ios/Platform/Platform/Features/Trips/Views/PastTripsSection.swift @@ -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) diff --git a/ios/Platform/Platform/Features/Trips/Views/TripsHomeView.swift b/ios/Platform/Platform/Features/Trips/Views/TripsHomeView.swift index 7e06b0d..057ce5a 100644 --- a/ios/Platform/Platform/Features/Trips/Views/TripsHomeView.swift +++ b/ios/Platform/Platform/Features/Trips/Views/TripsHomeView.swift @@ -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() - } } } diff --git a/ios/Platform/Platform/Features/Trips/Views/UpcomingTripsPageView.swift b/ios/Platform/Platform/Features/Trips/Views/UpcomingTripsPageView.swift index 62590a2..0fc0749 100644 --- a/ios/Platform/Platform/Features/Trips/Views/UpcomingTripsPageView.swift +++ b/ios/Platform/Platform/Features/Trips/Views/UpcomingTripsPageView.swift @@ -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) }