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:
parent
f3f94f1cfe
commit
bbcb7bd81f
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
118
readeck/UI/BookmarkDetail/ImageViewerView.swift
Normal file
118
readeck/UI/BookmarkDetail/ImageViewerView.swift
Normal 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")
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user