From dafeecddba9032008c04cc3b0bafd6c0ab82e1ef Mon Sep 17 00:00:00 2001 From: Adam Mischke Date: Tue, 11 Aug 2026 21:27:21 -0500 Subject: [PATCH 1/2] add gallery hero zoom animation with interactive drag-to-dismiss (#14) Present the gallery as a fullScreenCover with iOS 18's native zoom navigation transition instead of a sheet. Thumbnails register as matchedTransitionSource so the system drives the thumbnail->fullscreen expansion and Photos-style interactive drag-to-dismiss, replacing the scroll-bounce detection approach from the earlier WIP. Dismissing zooms back to whichever media is currently shown. Adds a close button and disables interactive dismiss while pinch-zoomed or scrubbing video. Claude-Session: https://claude.ai/code/session_01BzkuYBpheMbQn6EdRPT4wg --- swiftchan/Models/PresentationState.swift | 20 ++++++++ .../Services/AccessibilityIdentifiers.swift | 1 + .../Boards/Catalog/Thread/PostView.swift | 17 +++++-- .../Boards/Catalog/Thread/RepliesView.swift | 1 + .../Boards/Catalog/Thread/ThreadView.swift | 19 ++++---- .../Views/Media/Gallery/GalleryView.swift | 48 +++++++++---------- 6 files changed, 65 insertions(+), 41 deletions(-) diff --git a/swiftchan/Models/PresentationState.swift b/swiftchan/Models/PresentationState.swift index ca4d7a7a..6b2f8825 100644 --- a/swiftchan/Models/PresentationState.swift +++ b/swiftchan/Models/PresentationState.swift @@ -14,3 +14,23 @@ class PresentationState { var presentingIndex: Int = 0 var presentingReplies: Bool = false } + +extension EnvironmentValues { + /// Namespace used for the thumbnail → gallery zoom transition. + @Entry var galleryNamespace: Namespace.ID? + /// True for PostViews rendered inside RepliesView, so only one context + /// registers a matchedTransitionSource for a given media index at a time. + @Entry var inRepliesContext: Bool = false +} + +extension View { + /// Marks a thumbnail as the source of the gallery zoom transition. + @ViewBuilder + func galleryTransitionSource(id: Int, namespace: Namespace.ID?, isActive: Bool) -> some View { + if let namespace, isActive { + matchedTransitionSource(id: id, in: namespace) + } else { + self + } + } +} diff --git a/swiftchan/Services/AccessibilityIdentifiers.swift b/swiftchan/Services/AccessibilityIdentifiers.swift index 57a272c1..347cb22e 100644 --- a/swiftchan/Services/AccessibilityIdentifiers.swift +++ b/swiftchan/Services/AccessibilityIdentifiers.swift @@ -23,6 +23,7 @@ class AccessibilityIdentifiers { static func galleryMediaImage(_ index: Int) -> String { "\(index) Gallery Media Image" } + static let galleryCloseButton: String = "Gallery Close Button" static let saveToPhotosButton: String = "Save to Photos Button" static let saveToFilesButton: String = "Save to Files Button" static let copyToPasteboardButton: String = "Copy to Pasteboard Button" diff --git a/swiftchan/Views/Boards/Catalog/Thread/PostView.swift b/swiftchan/Views/Boards/Catalog/Thread/PostView.swift index 427686fc..e21af4bd 100644 --- a/swiftchan/Views/Boards/Catalog/Thread/PostView.swift +++ b/swiftchan/Views/Boards/Catalog/Thread/PostView.swift @@ -12,6 +12,8 @@ struct PostView: View { @Environment(ThreadViewModel.self) private var viewModel @Environment(AppState.self) private var appState @Environment(PresentationState.self) private var presentationState: PresentationState + @Environment(\.galleryNamespace) private var galleryNamespace + @Environment(\.inRepliesContext) private var inRepliesContext let index: Int @@ -50,12 +52,17 @@ struct PostView: View { .accessibilityIdentifier(AccessibilityIdentifiers.thumbnailMediaImage(index)) .frame(width: UIScreen.halfWidth) .scaledToFill() // VStack + .galleryTransitionSource( + id: mediaIndex, + namespace: galleryNamespace, + // Only one context may own a source id: the thread + // list normally, RepliesView while it is pushed. + isActive: inRepliesContext == presentationState.presentingReplies + ) .onTapGesture { - withAnimation(.easeInOut(duration: 0.3)) { - viewModel.media[mediaIndex].isSelected = true - presentationState.galleryIndex = mediaIndex - presentationState.presentingGallery = true - } + viewModel.media[mediaIndex].isSelected = true + presentationState.galleryIndex = mediaIndex + presentationState.presentingGallery = true } if let filename = post.filename, let fileExtension = post.ext { diff --git a/swiftchan/Views/Boards/Catalog/Thread/RepliesView.swift b/swiftchan/Views/Boards/Catalog/Thread/RepliesView.swift index 3df0d8a0..a89b6e76 100644 --- a/swiftchan/Views/Boards/Catalog/Thread/RepliesView.swift +++ b/swiftchan/Views/Boards/Catalog/Thread/RepliesView.swift @@ -28,6 +28,7 @@ struct RepliesView: View { } } } + .environment(\.inRepliesContext, true) .onOpenURL { url in if case .post(let id) = Deeplinker.getType(url: url) { showReply = true diff --git a/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift b/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift index 6777f79d..6adeeadd 100644 --- a/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift +++ b/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift @@ -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) + ) } ) .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 - } } } diff --git a/swiftchan/Views/Media/Gallery/GalleryView.swift b/swiftchan/Views/Media/Gallery/GalleryView.swift index d4977cc1..18d98b73 100644 --- a/swiftchan/Views/Media/Gallery/GalleryView.swift +++ b/swiftchan/Views/Media/Gallery/GalleryView.swift @@ -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) + } + @ViewBuilder private func mediaView(for index: Int) -> some View { if viewModel.media.indices.contains(index) { @@ -139,7 +146,6 @@ struct GalleryView: View { if zoomed { showPreview = false } - updateInteractiveDismiss() onMediaChanged?(zoomed) } .onSeekChanged { seeking in @@ -147,7 +153,6 @@ struct GalleryView: View { 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 { From 5daeb4346356dde9049ffe0b96a0e2ba2eeb5333 Mon Sep 17 00:00:00 2001 From: Adam Mischke Date: Tue, 11 Aug 2026 21:27:21 -0500 Subject: [PATCH 2/2] encode zero-width-space test fixtures as explicit unicode escapes The swiftlint build phase's invisible_character autocorrect was stripping the literal U+200B characters these URL-parser tests deliberately contain. Explicit \u{200B} escapes keep the fixtures intact and lint-clean. Claude-Session: https://claude.ai/code/session_01BzkuYBpheMbQn6EdRPT4wg --- swiftchanTests/swiftchanTests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/swiftchanTests/swiftchanTests.swift b/swiftchanTests/swiftchanTests.swift index afc2c6b0..876efddf 100644 --- a/swiftchanTests/swiftchanTests.swift +++ b/swiftchanTests/swiftchanTests.swift @@ -25,11 +25,11 @@ class SwiftchanTests: XCTestCase { Softimage Mod Tool: - http://usa.autodesk.com/adsk/servle​t/pc/item?id=13571257&siteID=123112 + http://usa.autodesk.com/adsk/servle\u{200B}t/pc/item?id=13571257&siteID=123112 Houdini Apprentice: - http://www.sidefx.com/index.php?opt​ion=com_download&Itemid=208&task=ap​prentice + http://www.sidefx.com/index.php?opt\u{200B}ion=com_download&Itemid=208&task=ap\u{200B}prentice """) print(result) XCTAssertEqual(result[0].0, URL(string: "http://www.blender.org/")!) @@ -39,8 +39,8 @@ class SwiftchanTests: XCTestCase { } func testHyperLinkFinderQueryParam() throws { - let urlString = "https://store.steampowered.com/app/​773840/DRAG/" - // let percentUrlString = "https://store.steampowered.com/app/​773840/DRAG/".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)! + let urlString = "https://store.steampowered.com/app/\u{200B}773840/DRAG/" + // let percentUrlString = "https://store.steampowered.com/app/\u{200B}773840/DRAG/".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)! let result = parser.checkForUrls(urlString) XCTAssertEqual(result[0].0, URL(string: "https://store.steampowered.com/app/773840/DRAG/")) }