fix: stabilize view tree for zoom transition recovery
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:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,29 +2,37 @@ 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 {
|
||||
if vm.isLoading && vm.trips.isEmpty {
|
||||
LoadingView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
if vm.isLoading && vm.trips.isEmpty {
|
||||
// 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)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
// Hero: upcoming trips (paged, full-width)
|
||||
UpcomingTripsPageView(trips: vm.upcomingTrips)
|
||||
UpcomingTripsPageView(trips: vm.upcomingTrips, namespace: namespace)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
// Secondary: past trips (horizontal scroll, compact cards)
|
||||
PastTripsSection(trips: vm.pastTrips)
|
||||
PastTripsSection(trips: vm.pastTrips, namespace: namespace)
|
||||
}
|
||||
}
|
||||
}
|
||||
.contentMargins(.bottom, 30, for: .scrollContent)
|
||||
@@ -32,11 +40,8 @@ struct TripsHomeView: View {
|
||||
.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user