Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions swiftchan.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -130,6 +131,7 @@
/* End PBXCopyFilesBuildPhase section */

/* Begin PBXFileReference section */
951BEC5E7078A59F1215DD0F /* GalleryNamespaceKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryNamespaceKey.swift; sourceTree = "<group>"; };
3078F0A90C5AA80AB504C21B /* RecurringFavorite.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecurringFavorite.swift; sourceTree = "<group>"; };
5DAF769CBA7D852E789BB9FE /* FavoritesView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FavoritesView.swift; sourceTree = "<group>"; };
83D12254DE163971EE1E9188 /* RecurringFavoriteViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecurringFavoriteViewModel.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -477,6 +479,7 @@
children = (
95C73105256CA1F700F18257 /* UserSettings.swift */,
9524E19E256F272200D08075 /* AppState.swift */,
951BEC5E7078A59F1215DD0F /* GalleryNamespaceKey.swift */,
);
path = Environment;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down
10 changes: 10 additions & 0 deletions swiftchan.xcodeproj/xcshareddata/xcschemes/swiftchan.xcscheme
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@
ReferencedContainer = "container:swiftchan.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "951817A8254CFB620032616E"
BuildableName = "swiftchanUITests.xctest"
BlueprintName = "swiftchanUITests"
ReferencedContainer = "container:swiftchan.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
Expand Down
17 changes: 17 additions & 0 deletions swiftchan/Environment/GalleryNamespaceKey.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//
// GalleryNamespaceKey.swift
// swiftchan
//

import SwiftUI

struct GalleryNamespaceKey: EnvironmentKey {
static let defaultValue: Namespace.ID? = nil
}

extension EnvironmentValues {
var galleryNamespace: Namespace.ID? {
get { self[GalleryNamespaceKey.self] }
set { self[GalleryNamespaceKey.self] = newValue }
}
}
1 change: 0 additions & 1 deletion swiftchan/Views/Boards/Catalog/OPView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import FourChan
struct OPView: View {
@AppStorage("showOPPreview") var showOPPreview: Bool = false
@Environment(AppState.self) var appState
@Namespace var fullscreenNspace

let index: Int
let boardName: String
Expand Down
9 changes: 8 additions & 1 deletion swiftchan/Views/Boards/Catalog/Thread/PostView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ 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

let index: Int

Expand Down Expand Up @@ -50,8 +51,14 @@ struct PostView: View {
.accessibilityIdentifier(AccessibilityIdentifiers.thumbnailMediaImage(index))
.frame(width: UIScreen.halfWidth)
.scaledToFill() // VStack
.matchedGeometryEffect(
id: "gallery-\(mediaIndex)",
in: galleryNamespace ?? Namespace().wrappedValue,
isSource: !(presentationState.presentingGallery && presentationState.galleryIndex == mediaIndex)
)
.opacity(presentationState.presentingGallery && presentationState.galleryIndex == mediaIndex ? 0 : 1)
.onTapGesture {
withAnimation(.easeInOut(duration: 0.3)) {
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
viewModel.media[mediaIndex].isSelected = true
presentationState.galleryIndex = mediaIndex
presentationState.presentingGallery = true
Expand Down
99 changes: 96 additions & 3 deletions swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ struct ThreadView: View {
@Environment(\.modelContext) private var modelContext
// @Query private var allFavorites: [FavoriteThread]

@Namespace private var galleryNamespace

@State private var presentationState = PresentationState()
@State private var threadAutorefresher = ThreadAutoRefresher()
@State var viewModel: ThreadViewModel
Expand All @@ -32,6 +34,12 @@ struct ThreadView: View {
@State private var autoRefreshToastMessage: String = ""
@State private var isSearching: Bool = false

// Gallery hero animation state
@State private var galleryDragOffset: CGFloat = 0
@State private var galleryBackgroundOpacity: Double = 1
@State private var isGalleryZoomed: Bool = false
@State private var isGallerySeeking: Bool = false

@State private var scene: SKScene = {
let s = SnowScene()
s.scaleMode = .resizeFill
Expand Down Expand Up @@ -101,6 +109,13 @@ struct ThreadView: View {
}
}
}
.scrollDisabled(presentationState.presentingGallery && !presentationState.presentingReplies)
}

// Gallery hero overlay
if presentationState.presentingGallery && !presentationState.presentingReplies {
galleryOverlayContent
.zIndex(10)
}
}
.overlay(alignment: .bottom) {
Expand All @@ -117,11 +132,12 @@ struct ThreadView: View {
}
}
.sheet(
isPresented: $presentationState.presentingGallery,
isPresented: Binding(
get: { presentationState.presentingGallery && presentationState.presentingReplies },
set: { if !$0 { presentationState.presentingGallery = false } }
),
onDismiss: {
// reneable this if it got disabled
UIApplication.shared.isIdleTimerDisabled = false

},
content: {
gallerySheetContent
Expand Down Expand Up @@ -173,7 +189,10 @@ struct ThreadView: View {
}
}
.environment(presentationState)
.environment(\.galleryNamespace, galleryNamespace)
.navigationTitle(viewModel.title)
.toolbar(presentationState.presentingGallery && !presentationState.presentingReplies ? .hidden : .automatic, for: .navigationBar)
.statusBar(hidden: presentationState.presentingGallery && !presentationState.presentingReplies)
.searchable(text: $viewModel.searchText, isPresented: $isSearching)
.onChange(of: viewModel.searchText) { _, _ in
viewModel.updateSearchResults()
Expand Down Expand Up @@ -398,6 +417,80 @@ struct ThreadView: View {
}

extension ThreadView {
// MARK: - Gallery Hero Overlay

@ViewBuilder
private var galleryOverlayContent: some View {
ZStack {
Color.black
.ignoresSafeArea()
.opacity(galleryBackgroundOpacity)

GalleryView(index: presentationState.galleryIndex)
.onMediaChanged { zoomed in
isGalleryZoomed = zoomed
}
.onSeekChanged { seeking in
isGallerySeeking = seeking
}
.onDismissDrag { translation in
galleryDragOffset = translation
let progress = min(translation / 300, 1)
galleryBackgroundOpacity = 1 - (progress * 0.6)
}
.onDismissDragEnded { velocity in
// Rubber-banded offset is small (~30-50pt for big drags), so use low threshold
// velocity.y < 0 means user was dragging content offset downward (finger moving down)
let offsetThreshold: CGFloat = 20
let velocityThreshold: CGFloat = 0.3
if galleryDragOffset > 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(
Expand Down
70 changes: 43 additions & 27 deletions swiftchan/Views/Media/Gallery/GalleryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
//

import SwiftUI
import SwiftUIIntrospect
import UIKit

struct GalleryView: View {
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}

Expand All @@ -139,15 +154,14 @@ struct GalleryView: View {
if zoomed {
showPreview = false
}
updateInteractiveDismiss()
onMediaChanged?(zoomed)
}
.onSeekChanged { seeking in
isSeeking = seeking
refreshPagingState()
canShowPreview = !seeking
canShowContextMenu = !seeking
updateInteractiveDismiss()
onSeekChanged?(seeking)
}
.mediaDownloadMenu(url: media.url, canShowContextMenu: $canShowContextMenu)
.accessibilityIdentifier(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
Loading
Loading