From 276b9db0690c519a385fdcb2a2505ca0196df951 Mon Sep 17 00:00:00 2001 From: Adam Mischke Date: Thu, 19 Mar 2026 12:38:25 -0500 Subject: [PATCH] WIP: gallery expanding animation with matchedGeometryEffect (#14) Replace sheet-based gallery presentation with ZStack overlay using matchedGeometryEffect for hero animation between thumbnail and fullscreen. Add drag-to-dismiss on first page via UIScrollView boundary detection, close button for other pages. Still janky - needs polish on interactive dismiss and animation timing. Co-Authored-By: Claude Opus 4.6 (1M context) --- swiftchan.xcodeproj/project.pbxproj | 4 + .../xcshareddata/xcschemes/swiftchan.xcscheme | 10 ++ .../Environment/GalleryNamespaceKey.swift | 17 ++++ swiftchan/Views/Boards/Catalog/OPView.swift | 1 - .../Boards/Catalog/Thread/PostView.swift | 9 +- .../Boards/Catalog/Thread/ThreadView.swift | 99 ++++++++++++++++++- .../Views/Media/Gallery/GalleryView.swift | 70 ++++++++----- .../Media/Gallery/VerticalPagerView.swift | 34 ++++++- swiftchanUITests/swiftchanUITests.swift | 47 ++++++++- 9 files changed, 257 insertions(+), 34 deletions(-) create mode 100644 swiftchan/Environment/GalleryNamespaceKey.swift diff --git a/swiftchan.xcodeproj/project.pbxproj b/swiftchan.xcodeproj/project.pbxproj index 2f2fe93b..1fbd5ac9 100644 --- a/swiftchan.xcodeproj/project.pbxproj +++ b/swiftchan.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 549DB3585CF7DBE5800AD093 /* GalleryNamespaceKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 951BEC5E7078A59F1215DD0F /* GalleryNamespaceKey.swift */; }; 09B1DFCA9F650DAC27B6DB21 /* RecurringFavorite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3078F0A90C5AA80AB504C21B /* RecurringFavorite.swift */; }; 1A5F76AD0CB8E1E22917C335 /* RecurringFavoriteViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83D12254DE163971EE1E9188 /* RecurringFavoriteViewModel.swift */; }; 2C999DFDE990B515FFB0B779 /* AddRecurringFavoriteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9044AE15507088A1373984CA /* AddRecurringFavoriteSheet.swift */; }; @@ -130,6 +131,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 951BEC5E7078A59F1215DD0F /* GalleryNamespaceKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryNamespaceKey.swift; sourceTree = ""; }; 3078F0A90C5AA80AB504C21B /* RecurringFavorite.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecurringFavorite.swift; sourceTree = ""; }; 5DAF769CBA7D852E789BB9FE /* FavoritesView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FavoritesView.swift; sourceTree = ""; }; 83D12254DE163971EE1E9188 /* RecurringFavoriteViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecurringFavoriteViewModel.swift; sourceTree = ""; }; @@ -477,6 +479,7 @@ children = ( 95C73105256CA1F700F18257 /* UserSettings.swift */, 9524E19E256F272200D08075 /* AppState.swift */, + 951BEC5E7078A59F1215DD0F /* GalleryNamespaceKey.swift */, ); path = Environment; sourceTree = ""; @@ -740,6 +743,7 @@ 95DBF2082712550C00356D8F /* AnimatedImage.swift in Sources */, 958D0998254D346A00AD4849 /* CatalogView.swift in Sources */, 95C73106256CA1F700F18257 /* UserSettings.swift in Sources */, + 549DB3585CF7DBE5800AD093 /* GalleryNamespaceKey.swift in Sources */, 95C01FE927386D2B000D64B1 /* SettingsView.swift in Sources */, 951053712566018D002E4051 /* Board.swift in Sources */, 95105379256608E5002E4051 /* CommentParser.swift in Sources */, diff --git a/swiftchan.xcodeproj/xcshareddata/xcschemes/swiftchan.xcscheme b/swiftchan.xcodeproj/xcshareddata/xcschemes/swiftchan.xcscheme index 02366520..e4517324 100644 --- a/swiftchan.xcodeproj/xcshareddata/xcschemes/swiftchan.xcscheme +++ b/swiftchan.xcodeproj/xcshareddata/xcschemes/swiftchan.xcscheme @@ -38,6 +38,16 @@ ReferencedContainer = "container:swiftchan.xcodeproj"> + + + + offsetThreshold || velocity < -velocityThreshold { + dismissGallery() + } else { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + galleryDragOffset = 0 + galleryBackgroundOpacity = 1 + } + } + } + .onClosePressed { + dismissGallery() + } + .environment(appState) + .environment(presentationState) + .environment(viewModel) + .matchedGeometryEffect( + id: "gallery-\(presentationState.galleryIndex)", + in: galleryNamespace, + isSource: true + ) + .offset(y: galleryDragOffset) + .scaleEffect(galleryDragScale) + .onAppear { + threadAutorefresher.cancelTimer() + } + .onDisappear { + threadAutorefresher.startTimer() + } + } + .transition(.opacity) + } + + private var galleryDragScale: CGFloat { + let progress = min(abs(galleryDragOffset) / 300, 1) + return 1 - (progress * 0.3) + } + + private func dismissGallery() { + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + presentationState.presentingGallery = false + galleryDragOffset = 0 + galleryBackgroundOpacity = 1 + } + UIApplication.shared.isIdleTimerDisabled = false + } + + // MARK: - Gallery Sheet (fallback for replies context) + @ViewBuilder private var gallerySheetContent: some View { let gallery = GalleryView( diff --git a/swiftchan/Views/Media/Gallery/GalleryView.swift b/swiftchan/Views/Media/Gallery/GalleryView.swift index d4977cc1..c229ba52 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,10 +25,13 @@ 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)? + var onSeekChanged: ((Bool) -> Void)? + var onDismissDrag: ((CGFloat) -> Void)? + var onDismissDragEnded: ((CGFloat) -> Void)? + var onClosePressed: (() -> Void)? init(index: Int) { self.index = index @@ -55,6 +57,16 @@ struct GalleryView: View { onDragEnded: { handlePagerDragEnded() }, + onDismissDrag: { translation in + if !isZoomed && !isSeeking { + onDismissDrag?(translation) + } + }, + onDismissDragEnded: { velocity in + if !isZoomed && !isSeeking { + onDismissDragEnded?(velocity) + } + }, onScrollViewCaptured: { scrollView in guard pagerScrollView !== scrollView else { return } DispatchQueue.main.async { @@ -105,24 +117,27 @@ struct GalleryView: View { .padding(.bottom, 60) } } + + // Close button + VStack { + HStack { + Spacer() + Button(action: { onClosePressed?() }) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 28)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.white) + .shadow(radius: 4) + } + .padding(16) + } + Spacer() + } } .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) - } .statusBar(hidden: true) } @@ -139,7 +154,6 @@ struct GalleryView: View { if zoomed { showPreview = false } - updateInteractiveDismiss() onMediaChanged?(zoomed) } .onSeekChanged { seeking in @@ -147,7 +161,7 @@ struct GalleryView: View { refreshPagingState() canShowPreview = !seeking canShowContextMenu = !seeking - updateInteractiveDismiss() + onSeekChanged?(seeking) } .mediaDownloadMenu(url: media.url, canShowContextMenu: $canShowContextMenu) .accessibilityIdentifier( @@ -183,7 +197,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) @@ -219,15 +232,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 { @@ -237,6 +241,18 @@ extension GalleryView: Buildable { func onPageDragChanged(_ callback: ((CGFloat) -> Void)?) -> Self { mutating(keyPath: \.onPageDragChanged, value: callback) } + func onSeekChanged(_ callback: ((Bool) -> Void)?) -> Self { + mutating(keyPath: \.onSeekChanged, value: callback) + } + func onDismissDrag(_ callback: ((CGFloat) -> Void)?) -> Self { + mutating(keyPath: \.onDismissDrag, value: callback) + } + func onDismissDragEnded(_ callback: ((CGFloat) -> Void)?) -> Self { + mutating(keyPath: \.onDismissDragEnded, value: callback) + } + func onClosePressed(_ callback: (() -> Void)?) -> Self { + mutating(keyPath: \.onClosePressed, value: callback) + } } #if DEBUG diff --git a/swiftchan/Views/Media/Gallery/VerticalPagerView.swift b/swiftchan/Views/Media/Gallery/VerticalPagerView.swift index 5f5268ff..a4a4afeb 100644 --- a/swiftchan/Views/Media/Gallery/VerticalPagerView.swift +++ b/swiftchan/Views/Media/Gallery/VerticalPagerView.swift @@ -15,6 +15,8 @@ struct VerticalPagerView: UIViewControllerRepresentable { var onPageChanged: ((Int) -> Void)? var onDragChanged: ((CGFloat) -> Void)? var onDragEnded: (() -> Void)? + var onDismissDrag: ((CGFloat) -> Void)? + var onDismissDragEnded: ((CGFloat) -> Void)? var onScrollViewCaptured: ((UIScrollView) -> Void)? var content: (Int) -> Content @@ -66,6 +68,8 @@ extension VerticalPagerView { weak var scrollView: UIScrollView? var currentIndex: Int = 0 var isSettingViewController = false + private var isDismissTracking = false + private var dismissVelocity: CGFloat = 0 init(parent: VerticalPagerView) { self.parent = parent @@ -156,12 +160,40 @@ extension VerticalPagerView { func scrollViewDidScroll(_ scrollView: UIScrollView) { guard parent.canScroll else { return } let baseline = scrollView.bounds.height - let translation = baseline - scrollView.contentOffset.y + let rawOffset = scrollView.contentOffset.y + + // Dismiss tracking: page 0, user dragging down past boundary + if currentIndex == 0 && rawOffset < baseline && !isSettingViewController { + if !isDismissTracking { + isDismissTracking = true + } + let dismissTranslation = baseline - rawOffset + parent.onDismissDrag?(dismissTranslation) + return + } + + // Only reset dismiss if user is actively dragging back up (not deceleration) + if isDismissTracking && rawOffset >= baseline && scrollView.isDragging { + isDismissTracking = false + parent.onDismissDrag?(0) + } + + let translation = baseline - rawOffset parent.onDragChanged?(translation) } func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { guard parent.canScroll else { return } + + if isDismissTracking { + isDismissTracking = false + // Force scroll back to rest position + targetContentOffset.pointee.y = scrollView.bounds.height + // Fire dismiss end with velocity immediately (before deceleration resets state) + parent.onDismissDragEnded?(velocity.y) + return + } + parent.onDragChanged?(0) } diff --git a/swiftchanUITests/swiftchanUITests.swift b/swiftchanUITests/swiftchanUITests.swift index e34c8032..5bcad459 100644 --- a/swiftchanUITests/swiftchanUITests.swift +++ b/swiftchanUITests/swiftchanUITests.swift @@ -6,7 +6,6 @@ // import XCTest -@testable import swiftchan class SwiftchanUITests: XCTestCase { var app: XCUIApplication! @@ -82,4 +81,50 @@ class SwiftchanUITests: XCTestCase { app.tapCopyToPasteboardButton() XCTAssert(UIPasteboard.general.hasURLs) } + + @MainActor func testGalleryExpandAnimation() throws { + // Navigate to a board and open a thread + app.goToBoard("a") + app.goToOPThread(0) + app.assertPost(0) + + // Screenshot 1: Thread view with thumbnail visible + let thumbnailBeforeTap = app.thumbnailMediaImage(0) + assertExistence(thumbnailBeforeTap) + let threadScreenshot = XCTAttachment(screenshot: app.screenshot()) + threadScreenshot.name = "1_thread_with_thumbnail" + threadScreenshot.lifetime = .keepAlways + add(threadScreenshot) + + // Tap the thumbnail to trigger the gallery hero animation + app.tapThumbnailMedia(0) + + // Screenshot 2: Gallery overlay should be visible + let galleryMedia = app.galleryMediaImage(0) + let galleryAppeared = galleryMedia.waitForExistence(timeout: 10) + let galleryScreenshot = XCTAttachment(screenshot: app.screenshot()) + galleryScreenshot.name = "2_gallery_expanded" + galleryScreenshot.lifetime = .keepAlways + add(galleryScreenshot) + + XCTAssert(galleryAppeared, "Gallery media should appear after tapping thumbnail") + + // Swipe down on first page to trigger interactive dismiss + galleryMedia.swipeDown(velocity: .slow) + Thread.sleep(forTimeInterval: 0.5) + + // Screenshot 3: After dismiss - should be back to thread view + let dismissScreenshot = XCTAttachment(screenshot: app.screenshot()) + dismissScreenshot.name = "3_after_dismiss" + dismissScreenshot.lifetime = .keepAlways + add(dismissScreenshot) + + // Verify thumbnail is visible again (gallery dismissed) + let thumbnailAfterDismiss = app.thumbnailMediaImage(0) + assertExistence(thumbnailAfterDismiss) + } + + private func assertExistence(_ element: XCUIElement, timeout: TimeInterval = 10) { + XCTAssert(element.waitForExistence(timeout: timeout), "Element not found: \(element.debugDescription)") + } }