237 lines
8.2 KiB
Swift
237 lines
8.2 KiB
Swift
import SwiftUI
|
||
|
||
// MARK: - FullScreenImageViewer
|
||
|
||
/// Full-screen image viewer with pinch zoom, double-tap zoom, and swipe-to-dismiss.
|
||
///
|
||
/// Android parity: `ImageViewerScreen.kt` — zoom (1x–5x), double-tap (2.5x),
|
||
/// vertical swipe dismiss, background fade, tap to toggle controls.
|
||
struct FullScreenImageViewer: View {
|
||
|
||
let image: UIImage
|
||
let onDismiss: () -> Void
|
||
|
||
/// Current zoom scale (1.0 = fit, up to maxScale).
|
||
@State private var scale: CGFloat = 1.0
|
||
@State private var lastScale: CGFloat = 1.0
|
||
|
||
/// Pan offset when zoomed.
|
||
@State private var offset: CGSize = .zero
|
||
@State private var lastOffset: CGSize = .zero
|
||
|
||
/// Vertical drag offset for dismiss gesture (only when not zoomed).
|
||
@State private var dismissOffset: CGFloat = 0
|
||
|
||
/// Whether the UI controls (close button) are visible.
|
||
@State private var showControls = true
|
||
|
||
private let minScale: CGFloat = 1.0
|
||
private let maxScale: CGFloat = 5.0
|
||
private let doubleTapScale: CGFloat = 2.5
|
||
private let dismissThreshold: CGFloat = 150
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
// Background: fades as user drags to dismiss
|
||
Color.black
|
||
.opacity(backgroundOpacity)
|
||
.ignoresSafeArea()
|
||
|
||
// Zoomable image (visual only — no gestures here)
|
||
Image(uiImage: image)
|
||
.resizable()
|
||
.scaledToFit()
|
||
.scaleEffect(scale)
|
||
.offset(x: offset.width, y: offset.height + dismissOffset)
|
||
.allowsHitTesting(false)
|
||
|
||
// Close button (above gesture layer so it stays tappable)
|
||
if showControls {
|
||
VStack {
|
||
HStack {
|
||
Spacer()
|
||
Button {
|
||
onDismiss()
|
||
} label: {
|
||
Image(systemName: "xmark")
|
||
.font(.system(size: 17, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.frame(width: 36, height: 36)
|
||
.background(Color.white.opacity(0.2))
|
||
.clipShape(Circle())
|
||
}
|
||
.padding(.trailing, 16)
|
||
.padding(.top, 8)
|
||
}
|
||
Spacer()
|
||
}
|
||
.transition(.opacity)
|
||
}
|
||
}
|
||
// Gestures on the full-screen ZStack — not on the Image.
|
||
// scaleEffect is visual-only and doesn't expand the Image's hit-test area,
|
||
// so when zoomed to 2.5x, taps outside the original frame were lost.
|
||
.contentShape(Rectangle())
|
||
.onTapGesture(count: 2) {
|
||
doubleTap()
|
||
}
|
||
.onTapGesture(count: 1) {
|
||
withAnimation(.easeInOut(duration: 0.2)) {
|
||
showControls.toggle()
|
||
}
|
||
}
|
||
.simultaneousGesture(pinchGesture)
|
||
.simultaneousGesture(dragGesture)
|
||
}
|
||
|
||
// MARK: - Background Opacity
|
||
|
||
private var backgroundOpacity: Double {
|
||
let progress = min(abs(dismissOffset) / 300, 1.0)
|
||
return 1.0 - progress * 0.6
|
||
}
|
||
|
||
// MARK: - Double Tap Zoom
|
||
|
||
private func doubleTap() {
|
||
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
|
||
if scale > 1.05 {
|
||
scale = 1.0
|
||
lastScale = 1.0
|
||
offset = .zero
|
||
lastOffset = .zero
|
||
} else {
|
||
scale = doubleTapScale
|
||
lastScale = doubleTapScale
|
||
offset = .zero
|
||
lastOffset = .zero
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Pinch Gesture
|
||
|
||
private var pinchGesture: some Gesture {
|
||
MagnificationGesture()
|
||
.onChanged { value in
|
||
let newScale = lastScale * value
|
||
scale = min(max(newScale, minScale * 0.5), maxScale)
|
||
}
|
||
.onEnded { _ in
|
||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||
if scale < minScale { scale = minScale }
|
||
lastScale = scale
|
||
if scale <= 1.0 {
|
||
offset = .zero
|
||
lastOffset = .zero
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Drag Gesture
|
||
|
||
private var dragGesture: some Gesture {
|
||
DragGesture()
|
||
.onChanged { value in
|
||
if scale > 1.05 {
|
||
offset = CGSize(
|
||
width: lastOffset.width + value.translation.width,
|
||
height: lastOffset.height + value.translation.height
|
||
)
|
||
} else {
|
||
dismissOffset = value.translation.height
|
||
}
|
||
}
|
||
.onEnded { _ in
|
||
if scale > 1.05 {
|
||
lastOffset = offset
|
||
} else {
|
||
if abs(dismissOffset) > dismissThreshold {
|
||
onDismiss()
|
||
} else {
|
||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||
dismissOffset = 0
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - FullScreenImageFromCache
|
||
|
||
/// Wrapper that loads an image from `AttachmentCache` by attachment ID and
|
||
/// presents it in `FullScreenImageViewer`. Handles cache-miss gracefully.
|
||
///
|
||
/// Used as `fullScreenCover` content — the attachment ID is a stable value
|
||
/// passed as a parameter, avoiding @State capture issues with UIImage.
|
||
struct FullScreenImageFromCache: View {
|
||
let attachmentId: String
|
||
let onDismiss: () -> Void
|
||
@State private var image: UIImage?
|
||
@State private var isLoading = true
|
||
|
||
var body: some View {
|
||
if let image {
|
||
FullScreenImageViewer(image: image, onDismiss: onDismiss)
|
||
} else {
|
||
// Cache miss/loading state — show placeholder with close button.
|
||
ZStack {
|
||
Color.black.ignoresSafeArea()
|
||
if isLoading {
|
||
VStack(spacing: 16) {
|
||
ProgressView()
|
||
.tint(.white)
|
||
Text("Loading...")
|
||
.font(.system(size: 15))
|
||
.foregroundStyle(.white.opacity(0.5))
|
||
}
|
||
} else {
|
||
VStack(spacing: 16) {
|
||
Image(systemName: "photo")
|
||
.font(.system(size: 48))
|
||
.foregroundStyle(.white.opacity(0.3))
|
||
Text("Image not available")
|
||
.font(.system(size: 15))
|
||
.foregroundStyle(.white.opacity(0.5))
|
||
}
|
||
}
|
||
VStack {
|
||
HStack {
|
||
Spacer()
|
||
Button {
|
||
onDismiss()
|
||
} label: {
|
||
Image(systemName: "xmark")
|
||
.font(.system(size: 17, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.frame(width: 36, height: 36)
|
||
.background(Color.white.opacity(0.2))
|
||
.clipShape(Circle())
|
||
}
|
||
.padding(.trailing, 16)
|
||
.padding(.top, 8)
|
||
}
|
||
Spacer()
|
||
}
|
||
}
|
||
.task(id: attachmentId) {
|
||
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachmentId) {
|
||
image = cached
|
||
isLoading = false
|
||
return
|
||
}
|
||
await ImageLoadLimiter.shared.acquire()
|
||
let loaded = await Task.detached(priority: .userInitiated) {
|
||
AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
|
||
}.value
|
||
await ImageLoadLimiter.shared.release()
|
||
guard !Task.isCancelled else { return }
|
||
image = loaded
|
||
isLoading = false
|
||
}
|
||
}
|
||
}
|
||
}
|