From bbcb7bd81fd0d80eb0f6ee35c38beb89d58f1b56 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Wed, 6 Aug 2025 22:19:00 +0200 Subject: [PATCH] feat: Add tappable hero image with zoom functionality - Add ImageViewerView for full-screen image viewing with zoom and pan - Make hero image in BookmarkDetailView tappable with zoom icon - Implement drag-to-dismiss functionality for image viewer - Extract ImageViewerView to separate file for better code organization - Add zoom icon (arrow symbol) to indicate tappable hero image - Support pinch-to-zoom (1x-4x), double-tap zoom, and pan gestures --- .../BookmarkDetail/BookmarkDetailView.swift | 35 ++++++ .../UI/BookmarkDetail/ImageViewerView.swift | 118 ++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 readeck/UI/BookmarkDetail/ImageViewerView.swift diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailView.swift b/readeck/UI/BookmarkDetail/BookmarkDetailView.swift index 92d6a34..4de602e 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailView.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailView.swift @@ -15,6 +15,7 @@ struct BookmarkDetailView: View { @State private var scrollViewHeight: CGFloat = 1 @State private var showJumpToProgressButton: Bool = false @State private var scrollPosition = ScrollPosition(edge: .top) + @State private var showingImageViewer = false // MARK: - Envs @@ -152,6 +153,9 @@ struct BookmarkDetailView: View { .sheet(isPresented: $showingLabelsSheet) { BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels) } + .sheet(isPresented: $showingImageViewer) { + ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl) + } .onChange(of: showingFontSettings) { _, isShowing in if !isShowing { // Reload settings when sheet is dismissed @@ -213,10 +217,41 @@ struct BookmarkDetailView: View { .frame(height: 240) .frame(maxWidth: .infinity) .offset(y: (offset > 0 ? -offset : 0)) + + // Tap area and zoom icon + VStack { + Spacer() + HStack { + Spacer() + Button(action: { + showingImageViewer = true + }) { + Image(systemName: "arrow.up.left.and.arrow.down.right") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white) + .padding(8) + .background( + Circle() + .fill(Color.black.opacity(0.6)) + .overlay( + Circle() + .stroke(Color.white.opacity(0.3), lineWidth: 1) + ) + ) + } + .padding(.trailing, 16) + .padding(.bottom, 16) + } + } + .frame(height: headerHeight + (offset > 0 ? offset : 0)) + .offset(y: (offset > 0 ? -offset : 0)) } } .frame(height: headerHeight) .ignoresSafeArea(edges: .top) + .onTapGesture { + showingImageViewer = true + } } } diff --git a/readeck/UI/BookmarkDetail/ImageViewerView.swift b/readeck/UI/BookmarkDetail/ImageViewerView.swift new file mode 100644 index 0000000..7ea88ba --- /dev/null +++ b/readeck/UI/BookmarkDetail/ImageViewerView.swift @@ -0,0 +1,118 @@ +import SwiftUI + +struct ImageViewerView: View { + let imageUrl: String + @Environment(\.dismiss) private var dismiss + @State private var scale: CGFloat = 1.0 + @State private var lastScale: CGFloat = 1.0 + @State private var offset: CGSize = .zero + @State private var lastOffset: CGSize = .zero + @State private var dragOffset: CGSize = .zero + @State private var isDraggingToDismiss = false + + var body: some View { + NavigationView { + GeometryReader { geometry in + ZStack { + Color.black + .ignoresSafeArea() + + AsyncImage(url: URL(string: imageUrl)) { image in + image + .resizable() + .scaledToFit() + .scaleEffect(scale) + .offset(offset) + .offset(dragOffset) + .opacity(isDraggingToDismiss ? 0.8 : 1.0) + .gesture( + SimultaneousGesture( + MagnificationGesture() + .onChanged { value in + let delta = value / lastScale + lastScale = value + scale = min(max(scale * delta, 1), 4) + } + .onEnded { _ in + lastScale = 1.0 + if scale < 1 { + withAnimation(.spring()) { + scale = 1 + offset = .zero + } + } + if scale > 4 { + scale = 4 + } + }, + DragGesture() + .onChanged { value in + if scale > 1 { + let newOffset = CGSize( + width: lastOffset.width + value.translation.width, + height: lastOffset.height + value.translation.height + ) + offset = newOffset + } else { + // Dismiss gesture when not zoomed + dragOffset = value.translation + let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2)) + if dragDistance > 50 { + isDraggingToDismiss = true + } + } + } + .onEnded { value in + if scale <= 1 { + lastOffset = offset + let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2)) + let velocity = sqrt(pow(value.velocity.width, 2) + pow(value.velocity.height, 2)) + + if dragDistance > 100 || velocity > 500 { + dismiss() + } else { + withAnimation(.spring()) { + dragOffset = .zero + isDraggingToDismiss = false + } + } + } else { + lastOffset = offset + } + } + ) + ) + .onTapGesture(count: 2) { + withAnimation(.spring()) { + if scale > 1 { + scale = 1 + offset = .zero + lastOffset = .zero + } else { + scale = 2 + } + } + } + } placeholder: { + ProgressView() + .scaleEffect(1.5) + .foregroundColor(.white) + } + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Close") { + dismiss() + } + .foregroundColor(.white) + } + } + } + } +} + +#Preview { + ImageViewerView(imageUrl: "https://example.com/image.jpg") +} \ No newline at end of file