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