diff --git a/Mlem.xcodeproj/project.pbxproj b/Mlem.xcodeproj/project.pbxproj index fe49ff7be..75419b6f5 100644 --- a/Mlem.xcodeproj/project.pbxproj +++ b/Mlem.xcodeproj/project.pbxproj @@ -481,6 +481,7 @@ CDF8426B2A4A2AB600723DA0 /* InboxItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF8426A2A4A2AB600723DA0 /* InboxItem.swift */; }; CDF9EF332AB2845C003F885B /* Icons.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF9EF322AB2845C003F885B /* Icons.swift */; }; E408C7882B0EE60000BE0A4A /* Routable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E408C7872B0EE60000BE0A4A /* Routable.swift */; }; + E409E16E2AFEFB8C0026FDC2 /* QuickLookState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E409E16D2AFEFB8C0026FDC2 /* QuickLookState.swift */; }; E40E018C2AABF85500410B2C /* AppRoutes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E40E018B2AABF85500410B2C /* AppRoutes.swift */; }; E40E018E2AABFBDE00410B2C /* AnyNavigationPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = E40E018D2AABFBDE00410B2C /* AnyNavigationPath.swift */; }; E40E01902AABFC9300410B2C /* AnyNavigablePath.swift in Sources */ = {isa = PBXBuildFile; fileRef = E40E018F2AABFC9300410B2C /* AnyNavigablePath.swift */; }; @@ -992,6 +993,7 @@ CDF8426A2A4A2AB600723DA0 /* InboxItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxItem.swift; sourceTree = ""; }; CDF9EF322AB2845C003F885B /* Icons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Icons.swift; sourceTree = ""; }; E408C7872B0EE60000BE0A4A /* Routable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Routable.swift; path = Routes/Routable.swift; sourceTree = ""; }; + E409E16D2AFEFB8C0026FDC2 /* QuickLookState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickLookState.swift; sourceTree = ""; }; E40E018B2AABF85500410B2C /* AppRoutes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRoutes.swift; sourceTree = ""; }; E40E018D2AABFBDE00410B2C /* AnyNavigationPath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyNavigationPath.swift; sourceTree = ""; }; E40E018F2AABFC9300410B2C /* AnyNavigablePath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyNavigablePath.swift; sourceTree = ""; }; @@ -1487,6 +1489,7 @@ 030D4AE52AA1273200A3393D /* ErrorDetails.swift */, CDCBD7232A8D62FF00387A2C /* InstanceMetadata.swift */, 50CC4A732A9CB10B0074C845 /* TimestampedValue.swift */, + E409E16D2AFEFB8C0026FDC2 /* QuickLookState.swift */, ); path = Models; sourceTree = ""; @@ -2989,6 +2992,7 @@ CD8461662A96F9EB0026A627 /* Website Indicator View.swift in Sources */, 038A16E92A7A9C640087987E /* LayoutWidget.swift in Sources */, 50811B2E2A92046D006BA3F2 /* URL+Mock.swift in Sources */, + E409E16E2AFEFB8C0026FDC2 /* QuickLookState.swift in Sources */, CD3FBCE52A4A89B900B2063F /* Mentions Feed View.swift in Sources */, CD391F962A535F5400E213B5 /* ResponseEditorView.swift in Sources */, 03EEEAF92ABB985D0087F8D8 /* CommunityModel.swift in Sources */, diff --git a/Mlem/ContentView.swift b/Mlem/ContentView.swift index e373f7026..e8ca9722f 100644 --- a/Mlem/ContentView.swift +++ b/Mlem/ContentView.swift @@ -33,6 +33,8 @@ struct ContentView: View { @AppStorage("homeButtonExists") var homeButtonExists: Bool = false @AppStorage("allowTabBarSwipeUpGesture") var allowTabBarSwipeUpGesture: Bool = true + @StateObject private var quickLookState: QuickLookState = .init() + var accessibilityFont: Bool { UIApplication.shared.preferredContentSizeCategory.isAccessibilityCategory } var body: some View { @@ -129,9 +131,13 @@ struct ContentView: View { .presentationDetents([.medium, .large], selection: .constant(.large)) ._presentationBackgroundInteraction(enabledUpThrough: .medium) } + .fullScreenCover(item: $quickLookState.url) { url in + QuickLookView(urls: [url]) + } .environment(\.openURL, OpenURLAction(handler: didReceiveURL)) .environmentObject(editorTracker) .environmentObject(unreadTracker) + .environmentObject(quickLookState) .onChange(of: scenePhase) { phase in // when app moves into background, hide the account switcher. This prevents the app from reopening with the switcher enabled. if phase != .active { diff --git a/Mlem/Extensions/View - Handle Lemmy Links.swift b/Mlem/Extensions/View - Handle Lemmy Links.swift index 7505f2edc..912dc473b 100644 --- a/Mlem/Extensions/View - Handle Lemmy Links.swift +++ b/Mlem/Extensions/View - Handle Lemmy Links.swift @@ -16,6 +16,7 @@ struct HandleLemmyLinksDisplay: ViewModifier { @EnvironmentObject private var layoutWidgetTracker: LayoutWidgetTracker @EnvironmentObject var appState: AppState @EnvironmentObject var filtersTracker: FiltersTracker + @EnvironmentObject private var quickLookState: QuickLookState @AppStorage("internetSpeed") var internetSpeed: InternetSpeed = .fast @@ -31,10 +32,12 @@ struct HandleLemmyLinksDisplay: ViewModifier { FeedView(community: community, feedType: .all) .environmentObject(appState) .environmentObject(filtersTracker) + .environmentObject(quickLookState) case .communityLinkWithContext(let context): FeedView(community: context.community, feedType: context.feedType) .environmentObject(appState) .environmentObject(filtersTracker) + .environmentObject(quickLookState) case .communitySidebarLinkWithContext(let context): CommunitySidebarView( community: context.community @@ -53,22 +56,29 @@ struct HandleLemmyLinksDisplay: ViewModifier { ExpandedPost(post: postModel) .environmentObject(postTracker) .environmentObject(appState) + .environmentObject(quickLookState) case .apiPost(let post): LazyLoadExpandedPost(post: post) + .environmentObject(quickLookState) case .apiPerson(let user): UserView(userID: user.id) + .environmentObject(quickLookState) case .userProfile(let user): UserView(userID: user.userId) .environmentObject(appState) + .environmentObject(quickLookState) case .postLinkWithContext(let post): ExpandedPost(post: post.post, scrollTarget: post.scrollTarget) .environmentObject(post.postTracker) .environmentObject(appState) + .environmentObject(quickLookState) case .lazyLoadPostLinkWithContext(let post): LazyLoadExpandedPost(post: post.post, scrollTarget: post.scrollTarget) + .environmentObject(quickLookState) case .userModeratorLink(let user): UserModeratorView(userDetails: user.user, moderatedCommunities: user.moderatedCommunities) .environmentObject(appState) + .environmentObject(quickLookState) case .settings(let page): settingsDestination(for: page) case .aboutSettings(let page): diff --git a/Mlem/Models/QuickLookState.swift b/Mlem/Models/QuickLookState.swift new file mode 100644 index 000000000..b1c62e0db --- /dev/null +++ b/Mlem/Models/QuickLookState.swift @@ -0,0 +1,12 @@ +// +// QuickLookState.swift +// Mlem +// +// Created by Bosco Ho on 2023-11-10. +// + +import Foundation + +final class QuickLookState: ObservableObject { + @Published var url: URL? +} diff --git a/Mlem/Views/Shared/Cached Image.swift b/Mlem/Views/Shared/Cached Image.swift index e72f2d1f6..a1fcb2629 100644 --- a/Mlem/Views/Shared/Cached Image.swift +++ b/Mlem/Views/Shared/Cached Image.swift @@ -19,7 +19,9 @@ struct CachedImage: View { // state vars to track the current image size and whether that size needs to be recomputed when the image actually loads. Combined with the image size cache, this produces good scrolling behavior except in the case where we scroll past an image and it derenders before it ever gets a chance to load, in which case that image will cause a slight hiccup on the way back up. That's kind of an unsolvable problem, since we can't know the size before we load the image at all, but that's fine because it shouldn't really happen during normal use. If we really want to guarantee smooth feed scrolling we can squish any image with no cached size into a square, but that feels like squishing a lot of images for the sake of a fringe case. @State var size: CGSize @State var shouldRecomputeSize: Bool - @State private var quickLookUrl: URL? + + @EnvironmentObject private var quickLookState: QuickLookState + @State private var isPresentingQuickLook = false var imageNotFound: () -> AnyView @@ -29,8 +31,8 @@ struct CachedImage: View { let cornerRadius: CGFloat let errorBackgroundColor: Color - // Optional callback triggered when the quicklook preview is dismissed - let dismissCallback: (() -> Void)? + // Optional callback triggered when the quicklook preview is presented on tap gesture. + let onTapCallback: (() -> Void)? init( url: URL?, @@ -40,7 +42,7 @@ struct CachedImage: View { imageNotFound: @escaping () -> AnyView = imageNotFoundDefault, errorBackgroundColor: Color = Color(uiColor: .systemGray4), contentMode: ContentMode = .fit, - dismissCallback: (() -> Void)? = nil, + onTapCallback: (() -> Void)? = nil, cornerRadius: CGFloat? = nil ) { self.url = url @@ -49,7 +51,7 @@ struct CachedImage: View { self.imageNotFound = imageNotFound self.errorBackgroundColor = errorBackgroundColor self.contentMode = contentMode - self.dismissCallback = dismissCallback + self.onTapCallback = onTapCallback self.cornerRadius = cornerRadius ?? 0 self.screenWidth = UIScreen.main.bounds.width - (AppConstants.postAndCommentSpacing * 2) @@ -95,6 +97,16 @@ struct CachedImage: View { .frame(maxHeight: size.height) .opacity(0.00000000001) } + .overlay { + if isPresentingQuickLook { + ProgressView() + .padding(12) + .background(.ultraThinMaterial) + .clipShape(Circle()) + .animation(.default, value: isPresentingQuickLook) + .transition(.opacity) + } + } .onAppear { // if the image appears and its size isn't cached, compute its size and cache it if shouldRecomputeSize { @@ -110,6 +122,7 @@ struct CachedImage: View { if shouldExpand { imageView .onTapGesture { + isPresentingQuickLook = true Task(priority: .userInitiated) { do { let (data, _) = try await ImagePipeline.shared.data(for: url!) @@ -121,21 +134,22 @@ struct CachedImage: View { } try data.write(to: quicklook) await MainActor.run { - quickLookUrl = quicklook + quickLookState.url = quicklook + /// Since quickLookState is a global state, calling callback on tap ensures we only call one callback (i.e. the one user requested to view). [2023.11] + onTapCallback?() + /// It takes some time for actual QuickLookUI to appear: + /// Ideally, progress view is removed once QuickLookUI fully appears. + /// [2023.11] + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + isPresentingQuickLook = false + } } } catch { print(String(describing: error)) + isPresentingQuickLook = false } } } - .onChange(of: quickLookUrl) { url in - if url == nil, let dismissCallback { - dismissCallback() - } - } - .fullScreenCover(item: $quickLookUrl) { url in - QuickLookView(urls: [url]) - } } else { imageView } diff --git a/Mlem/Views/Shared/Components/Thumbnail Image View.swift b/Mlem/Views/Shared/Components/Thumbnail Image View.swift index bff5ebc58..a8faaf881 100644 --- a/Mlem/Views/Shared/Components/Thumbnail Image View.swift +++ b/Mlem/Views/Shared/Components/Thumbnail Image View.swift @@ -32,7 +32,7 @@ struct ThumbnailImageView: View { url: url, fixedSize: size, contentMode: .fill, - dismissCallback: markPostAsRead + onTapCallback: markPostAsRead ) .blur(radius: showNsfwFilter ? 8 : 0) case let .link(url): diff --git a/Mlem/Views/Shared/Posts/Feed Post.swift b/Mlem/Views/Shared/Posts/Feed Post.swift index 462023363..354876ad0 100644 --- a/Mlem/Views/Shared/Posts/Feed Post.swift +++ b/Mlem/Views/Shared/Posts/Feed Post.swift @@ -13,7 +13,6 @@ // swiftlint:disable type_body_length import Dependencies -import QuickLook import SwiftUI /// Displays a single post in the feed diff --git a/Mlem/Views/Shared/Posts/Post Sizes/Large Post.swift b/Mlem/Views/Shared/Posts/Post Sizes/Large Post.swift index 983ed81d2..34cf6f830 100644 --- a/Mlem/Views/Shared/Posts/Post Sizes/Large Post.swift +++ b/Mlem/Views/Shared/Posts/Post Sizes/Large Post.swift @@ -203,7 +203,7 @@ struct LargePost: View { CachedImage( url: url, maxHeight: layoutMode.getMaxHeight(limitHeight), - dismissCallback: markPostAsRead, + onTapCallback: markPostAsRead, cornerRadius: AppConstants.largeItemCornerRadius ) .frame(