333 lines
12 KiB
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)
|
|
}
|
|
}
|
|
|
|
}
|