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

333 lines
12 KiB
Swift

import SwiftUI
import UIKit
import Photos
// MARK: - Data Types
/// Per-image metadata for the gallery viewer.
/// Android parity: `ViewableImage` in `ImageViewerScreen.kt`.
struct ViewableImageInfo: Equatable, Identifiable {
let attachmentId: String
let senderName: String
let timestamp: Date
let caption: String
var id: String { attachmentId }
}
/// State for the image gallery viewer.
struct ImageViewerState: Equatable {
let images: [ViewableImageInfo]
let initialIndex: Int
}
// MARK: - ImageViewerPresenter
/// UIHostingController subclass that hides the status bar.
/// Uses `AnyView` instead of generic `Content` to avoid a Swift compiler crash
/// in the SIL inliner (SR-XXXXX / rdar://XXXXX).
private final class StatusBarHiddenHostingController: UIHostingController<AnyView> {
override var prefersStatusBarHidden: Bool { true }
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation { .fade }
}
/// Presents the image gallery viewer using UIKit `overFullScreen` presentation
/// no bottom-sheet slide-up. Appears instantly; the viewer itself fades in.
/// Telegram parity: the viewer appears as a fade overlay covering nav bar and tab bar.
@MainActor
final class ImageViewerPresenter {
static let shared = ImageViewerPresenter()
private weak var presentedController: UIViewController?
func present(state: ImageViewerState) {
guard presentedController == nil else { return }
let viewer = ImageGalleryViewer(state: state, onDismiss: { [weak self] in
self?.dismiss()
})
let hostingController = StatusBarHiddenHostingController(rootView: AnyView(viewer))
hostingController.modalPresentationStyle = .overFullScreen
hostingController.view.backgroundColor = .clear
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let root = windowScene.keyWindow?.rootViewController
else { return }
var presenter = root
while let presented = presenter.presentedViewController {
presenter = presented
}
presenter.present(hostingController, animated: false)
presentedController = hostingController
}
func dismiss() {
presentedController?.dismiss(animated: false)
presentedController = nil
}
}
// MARK: - ImageGalleryViewer
/// Telegram-style multi-photo gallery viewer with horizontal paging.
/// Android parity: `ImageViewerScreen.kt` top bar with sender/date,
/// bottom caption bar, edge-tap navigation, velocity dismiss, share/save.
struct ImageGalleryViewer: View {
let state: ImageViewerState
let onDismiss: () -> Void
@State private var currentPage: Int
@State private var showControls = true
@State private var currentZoomScale: CGFloat = 1.0
@State private var backgroundOpacity: Double = 1.0
@State private var isDismissing = false
/// Entry/exit animation progress (0 = hidden, 1 = fully visible).
@State private var presentationAlpha: Double = 0
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "d MMMM, HH:mm"
return formatter
}()
init(state: ImageViewerState, onDismiss: @escaping () -> Void) {
self.state = state
self.onDismiss = onDismiss
self._currentPage = State(initialValue: state.initialIndex)
}
private var currentInfo: ViewableImageInfo? {
state.images.indices.contains(currentPage) ? state.images[currentPage] : nil
}
var body: some View {
ZStack {
// Background fades during drag-to-dismiss and entry/exit
Color.black
.opacity(backgroundOpacity * presentationAlpha)
.ignoresSafeArea()
// Pager
TabView(selection: $currentPage) {
ForEach(Array(state.images.enumerated()), id: \.element.attachmentId) { index, info in
ZoomableImagePage(
attachmentId: info.attachmentId,
onDismiss: { smoothDismiss() },
onDismissProgress: { progress in
backgroundOpacity = 1.0 - Double(progress) * 0.7
},
onDismissCancel: {
withAnimation(.easeOut(duration: 0.25)) {
backgroundOpacity = 1.0
}
},
showControls: $showControls,
currentScale: $currentZoomScale,
onEdgeTap: { direction in
navigateEdgeTap(direction: direction)
}
)
.tag(index)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
// Block TabView page swipe when zoomed or dismissing,
// but ONLY the scroll NOT all user interaction.
// .disabled() kills ALL gestures (taps, pinch, etc.) which prevents
// double-tap zoom out. .scrollDisabled() only blocks the page swipe.
.scrollDisabled(currentZoomScale > 1.05 || isDismissing)
.opacity(presentationAlpha)
// Controls overlay
controlsOverlay
.opacity(presentationAlpha)
}
.statusBarHidden(true)
.onAppear {
prefetchAdjacentImages(around: state.initialIndex)
// Android: 200ms entry animation (TelegramEasing)
withAnimation(.easeOut(duration: 0.2)) {
presentationAlpha = 1.0
}
}
.onChange(of: currentPage) { _, newPage in
prefetchAdjacentImages(around: newPage)
}
}
// MARK: - Controls Overlay
@ViewBuilder
private var controlsOverlay: some View {
VStack(spacing: 0) {
// Android parity: slide + fade, 200ms, FastOutSlowInEasing, 24pt slide distance.
if showControls && !isDismissing {
topBar
.transition(.move(edge: .top).combined(with: .opacity))
}
Spacer()
if showControls && !isDismissing {
bottomBar
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.easeOut(duration: 0.2), value: showControls)
}
// MARK: - Top Bar (Android: sender name + date, back arrow)
private var topBar: some View {
HStack(spacing: 8) {
// Back button (Android: arrow back on left)
Button { smoothDismiss() } label: {
Image(systemName: "chevron.left")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
}
// Sender name + date
if let info = currentInfo {
VStack(alignment: .leading, spacing: 1) {
Text(info.senderName)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.lineLimit(1)
Text(Self.dateFormatter.string(from: info.timestamp))
.font(.system(size: 13))
.foregroundStyle(.white.opacity(0.7))
}
}
Spacer()
// Page counter (if multiple images)
if state.images.count > 1 {
Text("\(currentPage + 1) / \(state.images.count)")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.8))
.padding(.trailing, 8)
}
}
.padding(.horizontal, 4)
.padding(.vertical, 8)
// Extend dark background up into the notch / Dynamic Island safe area
.background(Color.black.opacity(0.5).ignoresSafeArea(edges: .top))
}
// MARK: - Bottom Bar (Caption + Share/Save)
private var bottomBar: some View {
VStack(spacing: 0) {
// Caption text (Android: AppleEmojiText, 15sp, 4 lines max)
if let caption = currentInfo?.caption, !caption.isEmpty {
Text(caption)
.font(.system(size: 15))
.foregroundStyle(.white)
.lineLimit(4)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.black.opacity(0.5))
}
// Action buttons
HStack(spacing: 32) {
Button { shareCurrentImage() } label: {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
}
Spacer()
Button { saveCurrentImage() } label: {
Image(systemName: "square.and.arrow.down")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
}
}
.padding(.horizontal, 24)
.padding(.bottom, 8)
// Extend dark background down into the home indicator safe area
.background(Color.black.opacity(0.5).ignoresSafeArea(edges: .bottom))
}
}
// MARK: - Edge Tap Navigation
private func navigateEdgeTap(direction: Int) {
let target = currentPage + direction
guard target >= 0, target < state.images.count else { return }
// Android: instant page switch with short fade (120ms)
currentPage = target
}
// MARK: - Smooth Dismiss (Android: 200ms fade-out)
private func smoothDismiss() {
guard !isDismissing else { return }
isDismissing = true
withAnimation(.easeOut(duration: 0.2)) {
presentationAlpha = 0
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) {
onDismiss()
}
}
// MARK: - Actions
private func shareCurrentImage() {
guard let info = currentInfo,
let image = AttachmentCache.shared.loadImage(forAttachmentId: info.attachmentId)
else { return }
let activityVC = UIActivityViewController(activityItems: [image], applicationActivities: nil)
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let root = windowScene.keyWindow?.rootViewController {
var presenter = root
while let presented = presenter.presentedViewController {
presenter = presented
}
activityVC.popoverPresentationController?.sourceView = presenter.view
activityVC.popoverPresentationController?.sourceRect = CGRect(
x: presenter.view.bounds.midX, y: presenter.view.bounds.maxY - 50,
width: 0, height: 0
)
presenter.present(activityVC, animated: true)
}
}
private func saveCurrentImage() {
guard let info = currentInfo,
let image = AttachmentCache.shared.loadImage(forAttachmentId: info.attachmentId)
else { return }
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
guard status == .authorized || status == .limited else { return }
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
}
}
// MARK: - Prefetch
private func prefetchAdjacentImages(around index: Int) {
// Android: prefetches ±2 images from current page
for offset in [-2, -1, 1, 2] {
let i = index + offset
guard i >= 0, i < state.images.count else { continue }
_ = AttachmentCache.shared.loadImage(forAttachmentId: state.images[i].attachmentId)
}
}
}