-
Notifications
You must be signed in to change notification settings - Fork 0
Gallery hero zoom animation with interactive drag-to-dismiss (#14) #41
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,6 +31,7 @@ struct ThreadView: View { | |
| @State private var showAutoRefreshToast: Bool = false | ||
| @State private var autoRefreshToastMessage: String = "" | ||
| @State private var isSearching: Bool = false | ||
| @Namespace private var galleryNamespace | ||
|
|
||
| @State private var scene: SKScene = { | ||
| let s = SnowScene() | ||
|
|
@@ -118,7 +119,7 @@ struct ThreadView: View { | |
| .disabled(true) | ||
| } | ||
| } | ||
| .sheet( | ||
| .fullScreenCover( | ||
| isPresented: $presentationState.presentingGallery, | ||
| onDismiss: { | ||
| // reneable this if it got disabled | ||
|
|
@@ -127,6 +128,11 @@ struct ThreadView: View { | |
| }, | ||
| content: { | ||
| gallerySheetContent | ||
| // Zoom back to whichever media the user is on; | ||
| // scrollToPost keeps its thumbnail on screen. | ||
| .navigationTransition( | ||
| .zoom(sourceID: presentationState.galleryIndex, in: galleryNamespace) | ||
| ) | ||
|
Comment on lines
+131
to
+135
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- candidate files ---'
git ls-files | rg '(^|/)(ThreadView|GalleryView|RepliesView)\.swift$'
printf '%s\n' '--- ThreadView outline ---'
ast-grep outline swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift
printf '%s\n' '--- relevant symbols and call sites ---'
rg -n -C 5 'galleryIndex|scrollToPost|presentingReplies|fullScreenCover|navigationTransition|RepliesView|GalleryView' swiftchan/Views/Boards/Catalog/Thread
printf '%s\n' '--- line range under review ---'
cat -n swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift | sed -n '90,165p'Repository: vanities/swiftchan Length of output: 18137 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- ThreadView body and helpers ---'
cat -n swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift | sed -n '1,115p'
cat -n swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift | sed -n '390,465p'
printf '%s\n' '--- RepliesView ---'
cat -n swiftchan/Views/Boards/Catalog/Thread/RepliesView.swift
printf '%s\n' '--- GalleryView structure and paging ---'
ast-grep outline swiftchan/Views/Media/Gallery/GalleryView.swift
rg -n -C 8 'galleryIndex|TabView|selection|presentingGallery|dismiss|onDisappear|onChange' swiftchan/Views/Media/Gallery/GalleryView.swift
printf '%s\n' '--- PostView media source and reply context ---'
cat -n swiftchan/Views/Boards/Catalog/Thread/PostView.swift | sed -n '1,90p'
cat -n swiftchan/Views/Boards/Catalog/Thread/PostView.swift | sed -n '145,190p'
printf '%s\n' '--- presentation state declarations and media mappings ---'
rg -n -C 5 'class PresentationState|struct PresentationState|galleryIndex|presentingIndex|postMediaMapping|media:' swiftchanRepository: vanities/swiftchan Length of output: 47609 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- transition environment definitions ---'
cat -n swiftchan/Models/PresentationState.swift
rg -n -C 8 'galleryTransitionSource|inRepliesContext|galleryNamespace' swiftchan
printf '%s\n' '--- complete GalleryView state update path ---'
cat -n swiftchan/Views/Media/Gallery/GalleryView.swift | sed -n '130,205p'
printf '%s\n' '--- relevant navigation and cover modifiers ---'
rg -n -C 12 'NavigationStack|NavigationSplitView|navigationDestination|fullScreenCover|presentingReplies|presentingGallery' swiftchan/Views/Boards/Catalog/Thread swiftchan/Models
printf '%s\n' '--- repository changes near the reviewed code ---'
git diff --stat
git diff -- swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift swiftchan/Views/Boards/Catalog/Thread/RepliesView.swift swiftchan/Views/Boards/Catalog/Thread/PostView.swiftRepository: vanities/swiftchan Length of output: 39697 🏁 Script executed: #!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path
thread = Path("swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift").read_text()
replies = Path("swiftchan/Views/Boards/Catalog/Thread/RepliesView.swift").read_text()
post = Path("swiftchan/Views/Boards/Catalog/Thread/PostView.swift").read_text()
gallery = Path("swiftchan/Views/Media/Gallery/GalleryView.swift").read_text()
checks = {
"gallery transition uses galleryIndex": ".zoom(sourceID: presentationState.galleryIndex" in thread,
"gallery paging updates galleryIndex": "state.galleryIndex = index" in gallery,
"thread scroll is disabled in replies": "if !presentationState.presentingReplies && !showReply" in thread,
"replies view has a ScrollView": "ScrollView(.vertical" in replies,
"replies view lacks a ScrollViewReader": "ScrollViewReader" not in replies,
"replies view lacks galleryIndex change handling": ".onChange(of: presentationState.galleryIndex)" not in replies,
"reply context owns the active source": "inRepliesContext == presentationState.presentingReplies" in post,
}
for name, result in checks.items():
print(f"{'PASS' if result else 'FAIL'}: {name}")
if not all(checks.values()):
raise SystemExit(1)
PYRepository: vanities/swiftchan Length of output: 461 Keep the active reply thumbnail visible before dismissal. When Add a 🤖 Prompt for AI Agents |
||
| } | ||
| ) | ||
| .onOpenURL { url in | ||
|
|
@@ -175,6 +181,7 @@ struct ThreadView: View { | |
| } | ||
| } | ||
| .environment(presentationState) | ||
| .environment(\.galleryNamespace, galleryNamespace) | ||
| .navigationTitle(viewModel.title) | ||
| .searchable(text: $viewModel.searchText, isPresented: $isSearching) | ||
| .onChange(of: viewModel.searchText) { _, _ in | ||
|
|
@@ -445,7 +452,7 @@ struct ThreadView: View { | |
| extension ThreadView { | ||
| @ViewBuilder | ||
| private var gallerySheetContent: some View { | ||
| let gallery = GalleryView( | ||
| GalleryView( | ||
| index: presentationState.galleryIndex | ||
| ) | ||
| .environment(appState) | ||
|
|
@@ -457,14 +464,6 @@ extension ThreadView { | |
| .onDisappear { | ||
| threadAutorefresher.startTimer() | ||
| } | ||
|
|
||
| if #available(iOS 16.0, *) { | ||
| gallery | ||
| .presentationDetents([.large]) | ||
| .presentationDragIndicator(.visible) | ||
| } else { | ||
| gallery | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,7 +6,6 @@ | |
| // | ||
|
|
||
| import SwiftUI | ||
| import SwiftUIIntrospect | ||
| import UIKit | ||
|
|
||
| struct GalleryView: View { | ||
|
|
@@ -26,7 +25,6 @@ struct GalleryView: View { | |
| @State private var isSeeking = false | ||
| @State private var isZoomed = false | ||
| @State private var pagerScrollView: UIScrollView? | ||
| @State private var sheetPresentationController: UISheetPresentationController? | ||
|
|
||
| var onMediaChanged: ((Bool) -> Void)? | ||
| var onPageDragChanged: ((CGFloat) -> Void)? | ||
|
|
@@ -106,26 +104,35 @@ struct GalleryView: View { | |
| } | ||
| } | ||
| } | ||
| .overlay(alignment: .topLeading) { | ||
| closeButton | ||
| } | ||
| .onDisappear { | ||
| restorePagerScrolling() | ||
| sheetPresentationController?.presentedViewController.isModalInPresentation = false | ||
| } | ||
| .gesture(canShowPreview && showGalleryPreview ? showPreviewTap() : nil) | ||
| .introspect(.sheet, on: .iOS(.v17, .v18, .v26)) { controller in | ||
| controller.prefersGrabberVisible = true | ||
| controller.prefersScrollingExpandsWhenScrolledToEdge = false | ||
| controller.detents = [.large()] | ||
| // Defer state update to avoid "Modifying state during view update" warning | ||
| if sheetPresentationController !== controller { | ||
| DispatchQueue.main.async { | ||
| sheetPresentationController = controller | ||
| } | ||
| } | ||
| updateInteractiveDismiss(using: controller) | ||
| } | ||
| // Block the zoom transition's pan-to-dismiss while pinch-zoomed into | ||
| // media or scrubbing video, so those gestures keep priority. | ||
| .interactiveDismissDisabled(isZoomed || isSeeking) | ||
| .statusBar(hidden: true) | ||
| } | ||
|
|
||
| private var closeButton: some View { | ||
| Button { | ||
| state.presentingGallery = false | ||
| } label: { | ||
| Image(systemName: "xmark.circle.fill") | ||
| .font(.system(size: 28)) | ||
| .symbolRenderingMode(.hierarchical) | ||
| .foregroundStyle(.white) | ||
| .shadow(radius: 4) | ||
| } | ||
| .padding(16) | ||
| .opacity(isZoomed ? 0 : 1) | ||
| .animation(.easeInOut(duration: 0.15), value: isZoomed) | ||
| .accessibilityIdentifier(AccessibilityIdentifiers.galleryCloseButton) | ||
|
Comment on lines
+120
to
+133
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Disable the hidden close button. When 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| @ViewBuilder | ||
| private func mediaView(for index: Int) -> some View { | ||
| if viewModel.media.indices.contains(index) { | ||
|
|
@@ -139,15 +146,13 @@ struct GalleryView: View { | |
| if zoomed { | ||
| showPreview = false | ||
| } | ||
| updateInteractiveDismiss() | ||
| onMediaChanged?(zoomed) | ||
| } | ||
| .onSeekChanged { seeking in | ||
| isSeeking = seeking | ||
| refreshPagingState() | ||
| canShowPreview = !seeking | ||
| canShowContextMenu = !seeking | ||
| updateInteractiveDismiss() | ||
| } | ||
| .mediaDownloadMenu(url: media.url, canShowContextMenu: $canShowContextMenu) | ||
| .accessibilityIdentifier( | ||
|
|
@@ -183,7 +188,6 @@ struct GalleryView: View { | |
| var currentItem = viewModel.media[index] | ||
| currentItem.isSelected = true | ||
| viewModel.media[index] = currentItem | ||
| updateInteractiveDismiss() | ||
|
|
||
| // Dynamic prefetching: update prefetch window as user swipes | ||
| viewModel.prefetch(currentIndex: index) | ||
|
|
@@ -220,14 +224,6 @@ struct GalleryView: View { | |
| refreshPagingState() | ||
| } | ||
|
|
||
| private func updateInteractiveDismiss(using controller: UISheetPresentationController? = nil) { | ||
| let controller = controller ?? sheetPresentationController | ||
| guard let controller else { return } | ||
| let allowDismiss = !isZoomed && !isSeeking | ||
| DispatchQueue.main.async { | ||
| controller.presentedViewController.isModalInPresentation = !allowDismiss | ||
| } | ||
| } | ||
| } | ||
|
|
||
| extension GalleryView: Buildable { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
Repository: vanities/swiftchan
Length of output: 35102
🏁 Script executed:
Repository: vanities/swiftchan
Length of output: 49487
🏁 Script executed:
Repository: vanities/swiftchan
Length of output: 18627
🏁 Script executed:
Repository: vanities/swiftchan
Length of output: 7284
Deactivate the obscured list source for pushed
PostViewdestinations.Both
ThreadViewandRepliesViewregister the samemediaIndexas their pushedPostViewbecause the destination inherits the list’s context values. Track the active gallery-source owner and deactivate the obscured list source while a post-detail destination is visible.🤖 Prompt for AI Agents