Files
mobile-ios/Rosetta/Features/Chats/ChatDetail/FullScreenImageViewer.swift

237 lines
8.2 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
// MARK: - FullScreenImageViewer
/// Full-screen image viewer with pinch zoom, double-tap zoom, and swipe-to-dismiss.
///
/// Android parity: `ImageViewerScreen.kt` zoom (1x5x), 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
}
}
}
}