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
This commit is contained in:
Ilyas Hallak 2025-08-06 22:19:00 +02:00
parent f3f94f1cfe
commit bbcb7bd81f
2 changed files with 153 additions and 0 deletions

View File

@ -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
}
}
}

View File

@ -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")
}