Оптимизация FPS чата: ForEach fast path, keyboard animation без updateUIView, debounce pipeline, кэши с half-eviction, release notes механизм
This commit is contained in:
@@ -344,6 +344,7 @@ struct AttachmentPanelView: View {
|
||||
.padding(4)
|
||||
.background { tabBarBackground }
|
||||
.clipShape(Capsule())
|
||||
.contentShape(Capsule())
|
||||
.tabBarShadow()
|
||||
}
|
||||
|
||||
@@ -356,7 +357,13 @@ struct AttachmentPanelView: View {
|
||||
.fill(.clear)
|
||||
.glassEffect(.regular, in: .capsule)
|
||||
} else {
|
||||
TelegramGlassCapsule()
|
||||
// iOS < 26 — matches RosettaTabBar: .regularMaterial + border
|
||||
Capsule()
|
||||
.fill(.regularMaterial)
|
||||
.overlay(
|
||||
Capsule()
|
||||
.strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +407,10 @@ struct AttachmentPanelView: View {
|
||||
.fill(.clear)
|
||||
.glassEffect(.regular, in: .capsule)
|
||||
} else {
|
||||
TelegramGlassCapsule()
|
||||
// Matches RosettaTabBar selection indicator: .thinMaterial
|
||||
Capsule()
|
||||
.fill(.thinMaterial)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,11 +52,11 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIView, context: Context) {
|
||||
context.coordinator.actions = actions
|
||||
context.coordinator.previewShape = previewShape
|
||||
context.coordinator.readStatusText = readStatusText
|
||||
// PERF: only update callbacks (lightweight pointer swap).
|
||||
// Skip actions/previewShape/readStatusText — these involve array allocation
|
||||
// and struct copying on EVERY layout pass (40× cells × 8 keyboard ticks = 320/s).
|
||||
// Context menu will use stale actions until cell is recycled — acceptable trade-off.
|
||||
context.coordinator.onTap = onTap
|
||||
context.coordinator.replyQuoteHeight = replyQuoteHeight
|
||||
context.coordinator.onReplyQuoteTap = onReplyQuoteTap
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ private struct KeyboardSpacer: View {
|
||||
let composerHeight: CGFloat
|
||||
|
||||
var body: some View {
|
||||
let _ = PerformanceLogger.shared.track("keyboardSpacer.bodyEval")
|
||||
Color.clear.frame(height: composerHeight + keyboard.keyboardPadding + 4)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +35,7 @@ private struct KeyboardPaddedView<Content: View>: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let _ = PerformanceLogger.shared.track("keyboardPadded.bodyEval")
|
||||
content.offset(y: -(keyboard.keyboardPadding + extraPadding))
|
||||
}
|
||||
}
|
||||
@@ -87,8 +89,8 @@ struct ChatDetailView: View {
|
||||
@State private var showForwardPicker = false
|
||||
@State private var forwardingMessage: ChatMessage?
|
||||
@State private var messageToDelete: ChatMessage?
|
||||
/// State for the multi-photo gallery viewer (nil = dismissed).
|
||||
@State private var imageViewerState: ImageViewerState?
|
||||
// Image viewer is presented via ImageViewerPresenter (UIKit overFullScreen),
|
||||
// not via SwiftUI fullScreenCover, to avoid bottom-sheet slide-up animation.
|
||||
/// ID of message to scroll to (set when tapping a reply quote).
|
||||
@State private var scrollToMessageId: String?
|
||||
/// ID of message currently highlighted after scroll-to-reply navigation.
|
||||
@@ -173,10 +175,13 @@ struct ChatDetailView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
let _ = PerformanceLogger.shared.track("chatDetail.bodyEval")
|
||||
ZStack {
|
||||
messagesList(maxBubbleWidth: maxBubbleWidth)
|
||||
}
|
||||
.overlay { chatEdgeGradients }
|
||||
// FPS overlay — uncomment for performance testing:
|
||||
// .overlay { FPSOverlayView() }
|
||||
.overlay(alignment: .bottom) {
|
||||
if !route.isSystemAccount {
|
||||
KeyboardPaddedView {
|
||||
@@ -286,17 +291,8 @@ struct ChatDetailView: View {
|
||||
forwardMessage(message, to: targetRoute)
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: Binding(
|
||||
get: { imageViewerState != nil },
|
||||
set: { if !$0 { imageViewerState = nil } }
|
||||
)) {
|
||||
if let state = imageViewerState {
|
||||
ImageGalleryViewer(
|
||||
state: state,
|
||||
onDismiss: { imageViewerState = nil }
|
||||
)
|
||||
}
|
||||
}
|
||||
// Image viewer: presented via ImageViewerPresenter (UIKit overFullScreen + crossDissolve).
|
||||
// No .fullScreenCover — avoids the default bottom-sheet slide-up animation.
|
||||
.alert("Delete Message", isPresented: Binding(
|
||||
get: { messageToDelete != nil },
|
||||
set: { if !$0 { messageToDelete = nil } }
|
||||
@@ -688,14 +684,11 @@ private extension ChatDetailView {
|
||||
.frame(height: 4)
|
||||
.id(Self.scrollBottomAnchorId)
|
||||
|
||||
// Spacer for composer + keyboard — OUTSIDE LazyVStack so padding
|
||||
// changes only shift the LazyVStack as a whole block (cheap),
|
||||
// instead of re-laying out every cell inside it (expensive).
|
||||
// Spacer for composer + keyboard — OUTSIDE LazyVStack.
|
||||
// Isolated in KeyboardSpacer to avoid marking parent dirty.
|
||||
KeyboardSpacer(composerHeight: composerHeight)
|
||||
|
||||
// LazyVStack: only visible cells are loaded. Internal layout
|
||||
// is unaffected by the spacer above changing height.
|
||||
// LazyVStack: only visible cells are loaded.
|
||||
LazyVStack(spacing: 0) {
|
||||
// Sentinel for viewport-based scroll tracking.
|
||||
// Must be inside LazyVStack — regular VStack doesn't
|
||||
@@ -707,21 +700,26 @@ private extension ChatDetailView {
|
||||
|
||||
// PERF: iterate reversed messages directly, avoid Array(enumerated()) allocation.
|
||||
// Use message.id identity (stable) — integer indices shift on insert.
|
||||
// PERF: VStack wrapper ensures each ForEach element produces
|
||||
// exactly 1 view → SwiftUI uses FAST PATH (O(1) diffing).
|
||||
// Without it: conditional unreadSeparator makes element count
|
||||
// variable → SLOW PATH (O(n) full scan on every update).
|
||||
ForEach(messages.reversed()) { message in
|
||||
let index = messageIndex(for: message.id)
|
||||
let position = bubblePosition(for: index)
|
||||
messageRow(
|
||||
message,
|
||||
maxBubbleWidth: maxBubbleWidth,
|
||||
position: position
|
||||
)
|
||||
.scaleEffect(x: 1, y: -1) // flip each row back to normal
|
||||
VStack(spacing: 0) {
|
||||
let index = messageIndex(for: message.id)
|
||||
let position = bubblePosition(for: index)
|
||||
messageRow(
|
||||
message,
|
||||
maxBubbleWidth: maxBubbleWidth,
|
||||
position: position
|
||||
)
|
||||
.scaleEffect(x: 1, y: -1) // flip each row back to normal
|
||||
|
||||
// Unread Messages separator (Telegram style).
|
||||
// In inverted scroll, "above" visually = after in code.
|
||||
if message.id == firstUnreadMessageId {
|
||||
unreadSeparator
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
// Unread Messages separator (Telegram style).
|
||||
if message.id == firstUnreadMessageId {
|
||||
unreadSeparator
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -820,6 +818,7 @@ private extension ChatDetailView {
|
||||
|
||||
@ViewBuilder
|
||||
func messageRow(_ message: ChatMessage, maxBubbleWidth: CGFloat, position: BubblePosition) -> some View {
|
||||
let _ = PerformanceLogger.shared.track("chatDetail.rowEval")
|
||||
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
|
||||
let hasTail = position == .single || position == .bottom
|
||||
|
||||
@@ -974,14 +973,14 @@ private extension ChatDetailView {
|
||||
.padding(.top, 3)
|
||||
|
||||
// Forwarded image attachments — blurhash thumbnails (Android parity: ForwardedImagePreview).
|
||||
ForEach(Array(imageAttachments.enumerated()), id: \.element.id) { _, att in
|
||||
ForEach(imageAttachments, id: \.id) { att in
|
||||
forwardedImagePreview(attachment: att, width: imageContentWidth, outgoing: outgoing)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
// Forwarded file attachments.
|
||||
ForEach(Array(fileAttachments.enumerated()), id: \.element.id) { _, att in
|
||||
ForEach(fileAttachments, id: \.id) { att in
|
||||
forwardedFilePreview(attachment: att, outgoing: outgoing)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.top, 4)
|
||||
@@ -1221,27 +1220,30 @@ private extension ChatDetailView {
|
||||
}
|
||||
|
||||
// Non-image attachments (file, avatar) — padded
|
||||
// PERF: Group ensures 1 view per element → ForEach fast path.
|
||||
ForEach(otherAttachments, id: \.id) { attachment in
|
||||
switch attachment.type {
|
||||
case .file:
|
||||
MessageFileView(
|
||||
attachment: attachment,
|
||||
message: message,
|
||||
outgoing: outgoing
|
||||
)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.top, 4)
|
||||
case .avatar:
|
||||
MessageAvatarView(
|
||||
attachment: attachment,
|
||||
message: message,
|
||||
outgoing: outgoing
|
||||
)
|
||||
Group {
|
||||
switch attachment.type {
|
||||
case .file:
|
||||
MessageFileView(
|
||||
attachment: attachment,
|
||||
message: message,
|
||||
outgoing: outgoing
|
||||
)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.top, 4)
|
||||
case .avatar:
|
||||
MessageAvatarView(
|
||||
attachment: attachment,
|
||||
message: message,
|
||||
outgoing: outgoing
|
||||
)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.top, 4)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Caption text below image
|
||||
@@ -1384,7 +1386,11 @@ private extension ChatDetailView {
|
||||
/// Desktop parity: `TextParser.tsx` pattern `/:emoji_([a-zA-Z0-9_-]+):/`
|
||||
/// Android parity: `unifiedToEmoji()` in `AppleEmojiPicker.kt`
|
||||
private func parsedMarkdown(_ text: String) -> AttributedString {
|
||||
if let cached = Self.markdownCache[text] { return cached }
|
||||
if let cached = Self.markdownCache[text] {
|
||||
PerformanceLogger.shared.track("markdown.cacheHit")
|
||||
return cached
|
||||
}
|
||||
PerformanceLogger.shared.track("markdown.cacheMiss")
|
||||
|
||||
// Cross-platform: replace :emoji_CODE: shortcodes with native Unicode emoji.
|
||||
let withEmoji = EmojiParser.replaceShortcodes(in: text)
|
||||
@@ -1802,12 +1808,14 @@ private extension ChatDetailView {
|
||||
)
|
||||
}
|
||||
ForEach(otherAttachments, id: \.id) { attachment in
|
||||
switch attachment.type {
|
||||
case .file:
|
||||
MessageFileView(attachment: attachment, message: message, outgoing: outgoing).padding(.horizontal, 4).padding(.top, 4)
|
||||
case .avatar:
|
||||
MessageAvatarView(attachment: attachment, message: message, outgoing: outgoing).padding(.horizontal, 6).padding(.top, 4)
|
||||
default: EmptyView()
|
||||
Group {
|
||||
switch attachment.type {
|
||||
case .file:
|
||||
MessageFileView(attachment: attachment, message: message, outgoing: outgoing).padding(.horizontal, 4).padding(.top, 4)
|
||||
case .avatar:
|
||||
MessageAvatarView(attachment: attachment, message: message, outgoing: outgoing).padding(.horizontal, 6).padding(.top, 4)
|
||||
default: EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
if hasCaption {
|
||||
@@ -1934,16 +1942,28 @@ private extension ChatDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects all image attachment IDs from the current chat and opens the gallery.
|
||||
/// Collects all image attachments from the current chat and opens the gallery.
|
||||
/// Android parity: `extractImagesFromMessages` in `ImageViewerScreen.kt` — includes
|
||||
/// sender name, timestamp, and caption for each image.
|
||||
/// Uses `ImageViewerPresenter` (UIKit overFullScreen) instead of SwiftUI fullScreenCover
|
||||
/// to avoid the default bottom-sheet slide-up animation.
|
||||
func openImageViewer(attachmentId: String) {
|
||||
var allImageIds: [String] = []
|
||||
var allImages: [ViewableImageInfo] = []
|
||||
for message in messages {
|
||||
let senderName = senderDisplayName(for: message.fromPublicKey)
|
||||
let timestamp = Date(timeIntervalSince1970: Double(message.timestamp) / 1000)
|
||||
for attachment in message.attachments where attachment.type == .image {
|
||||
allImageIds.append(attachment.id)
|
||||
allImages.append(ViewableImageInfo(
|
||||
attachmentId: attachment.id,
|
||||
senderName: senderName,
|
||||
timestamp: timestamp,
|
||||
caption: message.text
|
||||
))
|
||||
}
|
||||
}
|
||||
let index = allImageIds.firstIndex(of: attachmentId) ?? 0
|
||||
imageViewerState = ImageViewerState(attachmentIds: allImageIds, initialIndex: index)
|
||||
let index = allImages.firstIndex(where: { $0.attachmentId == attachmentId }) ?? 0
|
||||
let state = ImageViewerState(images: allImages, initialIndex: index)
|
||||
ImageViewerPresenter.shared.present(state: state)
|
||||
}
|
||||
|
||||
func retryMessage(_ message: ChatMessage) {
|
||||
|
||||
@@ -27,10 +27,13 @@ final class ChatDetailViewModel: ObservableObject {
|
||||
// Subscribe to messagesByDialog changes, filtered to our dialog only.
|
||||
// Broken into steps to help the Swift type-checker.
|
||||
let key = dialogKey
|
||||
// Android parity: debounce(50ms) batches rapid message mutations
|
||||
// (delivery status, read marks, sync bursts) into fewer UI updates.
|
||||
let messagesPublisher = repo.$messagesByDialog
|
||||
.map { (dict: [String: [ChatMessage]]) -> [ChatMessage] in
|
||||
dict[key] ?? []
|
||||
}
|
||||
.debounce(for: .milliseconds(50), scheduler: DispatchQueue.main)
|
||||
.removeDuplicates { (lhs: [ChatMessage], rhs: [ChatMessage]) -> Bool in
|
||||
guard lhs.count == rhs.count else { return false }
|
||||
for i in lhs.indices {
|
||||
@@ -40,10 +43,10 @@ final class ChatDetailViewModel: ObservableObject {
|
||||
}
|
||||
return true
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
|
||||
messagesPublisher
|
||||
.sink { [weak self] newMessages in
|
||||
PerformanceLogger.shared.track("chatDetail.messagesEmit")
|
||||
self?.messages = newMessages
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
@@ -4,17 +4,76 @@ 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 attachmentIds: [String]
|
||||
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` — HorizontalPager, zoom-to-point,
|
||||
/// velocity dismiss, page counter, share/save.
|
||||
/// 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
|
||||
@@ -23,6 +82,16 @@ struct ImageGalleryViewer: View {
|
||||
@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
|
||||
@@ -30,76 +99,135 @@ struct ImageGalleryViewer: View {
|
||||
self._currentPage = State(initialValue: state.initialIndex)
|
||||
}
|
||||
|
||||
private var currentInfo: ViewableImageInfo? {
|
||||
state.images.indices.contains(currentPage) ? state.images[currentPage] : nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
// Background — fades during drag-to-dismiss and entry/exit
|
||||
Color.black
|
||||
.opacity(backgroundOpacity * presentationAlpha)
|
||||
.ignoresSafeArea()
|
||||
|
||||
// Pager
|
||||
TabView(selection: $currentPage) {
|
||||
ForEach(Array(state.attachmentIds.enumerated()), id: \.element) { index, attachmentId in
|
||||
ForEach(Array(state.images.enumerated()), id: \.element.attachmentId) { index, info in
|
||||
ZoomableImagePage(
|
||||
attachmentId: attachmentId,
|
||||
onDismiss: onDismiss,
|
||||
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
|
||||
currentScale: $currentZoomScale,
|
||||
onEdgeTap: { direction in
|
||||
navigateEdgeTap(direction: direction)
|
||||
}
|
||||
)
|
||||
.tag(index)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.disabled(currentZoomScale > 1.05)
|
||||
.disabled(currentZoomScale > 1.05 || isDismissing)
|
||||
.opacity(presentationAlpha)
|
||||
|
||||
// Controls overlay
|
||||
if showControls {
|
||||
controlsOverlay
|
||||
.transition(.opacity)
|
||||
}
|
||||
controlsOverlay
|
||||
.opacity(presentationAlpha)
|
||||
}
|
||||
.statusBarHidden(true)
|
||||
.animation(.easeInOut(duration: 0.2), value: showControls)
|
||||
.onChange(of: currentPage) { _, newPage in
|
||||
prefetchAdjacentImages(around: newPage)
|
||||
}
|
||||
.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 {
|
||||
// Top bar: close + counter — inside safe area to avoid notch/Dynamic Island overlap
|
||||
HStack {
|
||||
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(.leading, 16)
|
||||
|
||||
if showControls && !isDismissing {
|
||||
VStack(spacing: 0) {
|
||||
topBar
|
||||
Spacer()
|
||||
|
||||
if state.attachmentIds.count > 1 {
|
||||
Text("\(currentPage + 1) / \(state.attachmentIds.count)")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Invisible spacer to balance the close button
|
||||
Color.clear.frame(width: 36, height: 36)
|
||||
.padding(.trailing, 16)
|
||||
bottomBar
|
||||
}
|
||||
.transition(.opacity)
|
||||
.animation(.easeInOut(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))
|
||||
}
|
||||
}
|
||||
.padding(.top, 54)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Bottom bar: share + save
|
||||
// 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")
|
||||
@@ -118,15 +246,41 @@ struct ImageGalleryViewer: View {
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 34)
|
||||
.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 currentPage < state.attachmentIds.count,
|
||||
let image = AttachmentCache.shared.loadImage(forAttachmentId: state.attachmentIds[currentPage])
|
||||
guard let info = currentInfo,
|
||||
let image = AttachmentCache.shared.loadImage(forAttachmentId: info.attachmentId)
|
||||
else { return }
|
||||
|
||||
let activityVC = UIActivityViewController(activityItems: [image], applicationActivities: nil)
|
||||
@@ -146,8 +300,8 @@ struct ImageGalleryViewer: View {
|
||||
}
|
||||
|
||||
private func saveCurrentImage() {
|
||||
guard currentPage < state.attachmentIds.count,
|
||||
let image = AttachmentCache.shared.loadImage(forAttachmentId: state.attachmentIds[currentPage])
|
||||
guard let info = currentInfo,
|
||||
let image = AttachmentCache.shared.loadImage(forAttachmentId: info.attachmentId)
|
||||
else { return }
|
||||
|
||||
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
|
||||
@@ -159,11 +313,12 @@ struct ImageGalleryViewer: View {
|
||||
// MARK: - Prefetch
|
||||
|
||||
private func prefetchAdjacentImages(around index: Int) {
|
||||
for offset in [-1, 1] {
|
||||
// Android: prefetches ±2 images from current page
|
||||
for offset in [-2, -1, 1, 2] {
|
||||
let i = index + offset
|
||||
guard i >= 0, i < state.attachmentIds.count else { continue }
|
||||
// Touch cache to warm it (loads from disk if needed)
|
||||
_ = AttachmentCache.shared.loadImage(forAttachmentId: state.attachmentIds[i])
|
||||
guard i >= 0, i < state.images.count else { continue }
|
||||
_ = AttachmentCache.shared.loadImage(forAttachmentId: state.images[i].attachmentId)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -152,11 +152,15 @@ struct MessageImageView: View {
|
||||
/// Calculates display size respecting min/max constraints and aspect ratio (standalone mode).
|
||||
private func constrainedSize(for img: UIImage) -> CGSize {
|
||||
let constrainedWidth = min(maxImageWidth, maxWidth)
|
||||
let aspectRatio = img.size.width / max(img.size.height, 1)
|
||||
// Guard: zero-size images (corrupted or failed downsampling) use placeholder size.
|
||||
guard img.size.width > 0, img.size.height > 0 else {
|
||||
return CGSize(width: min(placeholderWidth, constrainedWidth), height: placeholderHeight)
|
||||
}
|
||||
let aspectRatio = img.size.width / img.size.height
|
||||
let displayWidth = min(constrainedWidth, max(minImageWidth, img.size.width))
|
||||
let displayHeight = min(maxImageHeight, max(minImageHeight, displayWidth / aspectRatio))
|
||||
let displayHeight = min(maxImageHeight, max(minImageHeight, displayWidth / max(aspectRatio, 0.01)))
|
||||
let finalWidth = min(constrainedWidth, displayHeight * aspectRatio)
|
||||
return CGSize(width: finalWidth, height: displayHeight)
|
||||
return CGSize(width: max(finalWidth, 1), height: max(displayHeight, 1))
|
||||
}
|
||||
|
||||
// MARK: - Placeholder
|
||||
@@ -214,6 +218,7 @@ struct MessageImageView: View {
|
||||
// MARK: - Download
|
||||
|
||||
private func loadFromCache() {
|
||||
PerformanceLogger.shared.track("image.cacheLoad")
|
||||
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
||||
image = cached
|
||||
}
|
||||
|
||||
@@ -3,71 +3,40 @@ import UIKit
|
||||
|
||||
// MARK: - ZoomableImagePage
|
||||
|
||||
/// Single page in the image gallery viewer with centroid-based zoom.
|
||||
/// Android parity: `ZoomableImage` in `ImageViewerScreen.kt` — pinch zoom to centroid,
|
||||
/// double-tap to tap point, velocity-based dismiss, touch slop.
|
||||
/// Single page in the image gallery viewer with UIKit-based gesture handling.
|
||||
/// Android parity: `ZoomableImage` in `ImageViewerScreen.kt` — centroid-based pinch zoom,
|
||||
/// double-tap to tap point, velocity-based dismiss, axis locking, edge tap navigation.
|
||||
struct ZoomableImagePage: View {
|
||||
|
||||
let attachmentId: String
|
||||
let onDismiss: () -> Void
|
||||
let onDismissProgress: (CGFloat) -> Void
|
||||
let onDismissCancel: () -> Void
|
||||
@Binding var showControls: Bool
|
||||
@Binding var currentScale: CGFloat
|
||||
let onEdgeTap: ((Int) -> Void)?
|
||||
|
||||
@State private var image: UIImage?
|
||||
@State private var scale: CGFloat = 1.0
|
||||
@State private var offset: CGSize = .zero
|
||||
@State private var dismissOffset: CGFloat = 0
|
||||
@State private var dismissStartTime: Date?
|
||||
|
||||
private let minScale: CGFloat = 1.0
|
||||
private let maxScale: CGFloat = 5.0
|
||||
private let doubleTapScale: CGFloat = 2.5
|
||||
private let dismissDistanceThreshold: CGFloat = 100
|
||||
private let dismissVelocityThreshold: CGFloat = 800
|
||||
private let touchSlop: CGFloat = 20
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// Background fade during dismiss
|
||||
Color.black
|
||||
.opacity(backgroundOpacity)
|
||||
.ignoresSafeArea()
|
||||
|
||||
if let image {
|
||||
imageContent(image, in: geometry)
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
Group {
|
||||
if let image {
|
||||
ZoomableImageUIViewRepresentable(
|
||||
image: image,
|
||||
onDismiss: onDismiss,
|
||||
onDismissProgress: onDismissProgress,
|
||||
onDismissCancel: onDismissCancel,
|
||||
onToggleControls: { showControls.toggle() },
|
||||
onScaleChanged: { scale in currentScale = scale },
|
||||
onEdgeTap: onEdgeTap
|
||||
)
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
}
|
||||
.task {
|
||||
image = AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
|
||||
}
|
||||
.onChange(of: scale) { _, newValue in
|
||||
currentScale = newValue
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Image Content
|
||||
|
||||
@ViewBuilder
|
||||
private func imageContent(_ image: UIImage, in geometry: GeometryProxy) -> some View {
|
||||
let size = geometry.size
|
||||
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.scaleEffect(scale)
|
||||
.offset(x: offset.width, y: offset.height + dismissOffset)
|
||||
.gesture(doubleTapGesture(in: size))
|
||||
.gesture(pinchGesture(in: size))
|
||||
.gesture(dragGesture(in: size))
|
||||
.onTapGesture {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showControls.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Placeholder
|
||||
@@ -81,118 +50,393 @@ struct ZoomableImagePage: View {
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Background Opacity
|
||||
// MARK: - UIViewRepresentable
|
||||
|
||||
private var backgroundOpacity: Double {
|
||||
let progress = min(abs(dismissOffset) / 300, 1.0)
|
||||
return 1.0 - progress * 0.6
|
||||
/// Wraps `ImageGestureContainerView` for SwiftUI integration.
|
||||
private struct ZoomableImageUIViewRepresentable: UIViewRepresentable {
|
||||
|
||||
let image: UIImage
|
||||
let onDismiss: () -> Void
|
||||
let onDismissProgress: (CGFloat) -> Void
|
||||
let onDismissCancel: () -> Void
|
||||
let onToggleControls: () -> Void
|
||||
let onScaleChanged: (CGFloat) -> Void
|
||||
let onEdgeTap: ((Int) -> Void)?
|
||||
|
||||
func makeUIView(context: Context) -> ImageGestureContainerView {
|
||||
let view = ImageGestureContainerView(image: image)
|
||||
view.onDismiss = onDismiss
|
||||
view.onDismissProgress = onDismissProgress
|
||||
view.onDismissCancel = onDismissCancel
|
||||
view.onToggleControls = onToggleControls
|
||||
view.onScaleChanged = onScaleChanged
|
||||
view.onEdgeTap = onEdgeTap
|
||||
return view
|
||||
}
|
||||
|
||||
// MARK: - Double Tap (zoom to tap point)
|
||||
func updateUIView(_ view: ImageGestureContainerView, context: Context) {
|
||||
view.onDismiss = onDismiss
|
||||
view.onDismissProgress = onDismissProgress
|
||||
view.onDismissCancel = onDismissCancel
|
||||
view.onToggleControls = onToggleControls
|
||||
view.onScaleChanged = onScaleChanged
|
||||
view.onEdgeTap = onEdgeTap
|
||||
}
|
||||
}
|
||||
|
||||
private func doubleTapGesture(in size: CGSize) -> some Gesture {
|
||||
SpatialTapGesture(count: 2)
|
||||
.onEnded { value in
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
|
||||
if scale > 1.05 {
|
||||
// Zoom out to 1x
|
||||
scale = 1.0
|
||||
offset = .zero
|
||||
} else {
|
||||
// Zoom in to tap point
|
||||
let tapPoint = value.location
|
||||
let viewCenter = CGPoint(x: size.width / 2, y: size.height / 2)
|
||||
scale = doubleTapScale
|
||||
// Shift image so tap point ends up at screen center
|
||||
offset = CGSize(
|
||||
width: (viewCenter.x - tapPoint.x) * (doubleTapScale - 1),
|
||||
height: (viewCenter.y - tapPoint.y) * (doubleTapScale - 1)
|
||||
)
|
||||
// MARK: - ImageGestureContainerView
|
||||
|
||||
/// UIKit view that handles all image gestures with full control:
|
||||
/// - Centroid-based pinch zoom (1x–5x)
|
||||
/// - Double-tap to zoom to tap point (2.5x) or reset
|
||||
/// - Pan when zoomed (with offset clamping)
|
||||
/// - Vertical drag to dismiss with velocity tracking
|
||||
/// - Single tap: edge zones navigate, center toggles controls
|
||||
/// - Axis locking: decides vertical dismiss vs pan early
|
||||
///
|
||||
/// Android parity: `ZoomableImage` in `ImageViewerScreen.kt`
|
||||
final class ImageGestureContainerView: UIView, UIGestureRecognizerDelegate {
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
private let minScale: CGFloat = 1.0
|
||||
private let maxScale: CGFloat = 5.0
|
||||
private let doubleTapScale: CGFloat = 2.5
|
||||
private let dismissDistanceThreshold: CGFloat = 100
|
||||
private let dismissVelocityThreshold: CGFloat = 500
|
||||
private let touchSlop: CGFloat = 20
|
||||
/// Android: left/right 20% zones are edge-tap navigation areas.
|
||||
private let edgeTapFraction: CGFloat = 0.20
|
||||
/// Android: spring(dampingRatio = 0.9, stiffness = 400) ≈ UIKit(damping: 0.9, velocity: 0)
|
||||
private let springDamping: CGFloat = 0.9
|
||||
private let springDuration: CGFloat = 0.35
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
private let imageView = UIImageView()
|
||||
|
||||
// MARK: - Transform State
|
||||
|
||||
private var currentScale: CGFloat = 1.0
|
||||
private var currentOffset: CGPoint = .zero
|
||||
private var dismissOffset: CGFloat = 0
|
||||
|
||||
// Pinch gesture tracking
|
||||
private var pinchStartScale: CGFloat = 1.0
|
||||
private var pinchStartOffset: CGPoint = .zero
|
||||
private var lastPinchCentroid: CGPoint = .zero
|
||||
|
||||
// Pan gesture tracking
|
||||
private var panStartOffset: CGPoint = .zero
|
||||
private var isDismissGesture = false
|
||||
private var gestureAxisLocked = false
|
||||
|
||||
// MARK: - Callbacks
|
||||
|
||||
var onDismiss: (() -> Void)?
|
||||
var onDismissProgress: ((CGFloat) -> Void)?
|
||||
var onDismissCancel: (() -> Void)?
|
||||
var onToggleControls: (() -> Void)?
|
||||
var onScaleChanged: ((CGFloat) -> Void)?
|
||||
/// -1 = left edge, 1 = right edge
|
||||
var onEdgeTap: ((Int) -> Void)?
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init(image: UIImage) {
|
||||
super.init(frame: .zero)
|
||||
imageView.image = image
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.isUserInteractionEnabled = false
|
||||
addSubview(imageView)
|
||||
clipsToBounds = true
|
||||
setupGestures()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
/// Track the last laid-out size so we only reset frame when it actually changes.
|
||||
/// Without this, SwiftUI state changes (e.g. `onDismissProgress`) trigger
|
||||
/// `layoutSubviews` → `imageView.frame = bounds` which RESETS the UIKit transform,
|
||||
/// causing the image to snap back during dismiss drag.
|
||||
private var lastLayoutSize: CGSize = .zero
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
guard lastLayoutSize != bounds.size else { return }
|
||||
lastLayoutSize = bounds.size
|
||||
// Temporarily reset transform, update frame, then re-apply.
|
||||
let savedTransform = imageView.transform
|
||||
imageView.transform = .identity
|
||||
imageView.frame = bounds
|
||||
imageView.transform = savedTransform
|
||||
}
|
||||
|
||||
// MARK: - Gesture Setup
|
||||
|
||||
private func setupGestures() {
|
||||
let pinch = UIPinchGestureRecognizer(target: self, action: #selector(handlePinch))
|
||||
pinch.delegate = self
|
||||
addGestureRecognizer(pinch)
|
||||
|
||||
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
|
||||
pan.delegate = self
|
||||
pan.maximumNumberOfTouches = 1
|
||||
addGestureRecognizer(pan)
|
||||
|
||||
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap))
|
||||
doubleTap.numberOfTapsRequired = 2
|
||||
addGestureRecognizer(doubleTap)
|
||||
|
||||
let singleTap = UITapGestureRecognizer(target: self, action: #selector(handleSingleTap))
|
||||
singleTap.numberOfTapsRequired = 1
|
||||
singleTap.require(toFail: doubleTap)
|
||||
addGestureRecognizer(singleTap)
|
||||
}
|
||||
|
||||
// MARK: - Apply Transform
|
||||
|
||||
private func applyTransform(animated: Bool = false) {
|
||||
// Guard against NaN/Infinity — prevents CoreGraphics crash and UI freeze.
|
||||
if currentScale.isNaN || currentScale.isInfinite { currentScale = 1.0 }
|
||||
if currentOffset.x.isNaN || currentOffset.x.isInfinite { currentOffset.x = 0 }
|
||||
if currentOffset.y.isNaN || currentOffset.y.isInfinite { currentOffset.y = 0 }
|
||||
if dismissOffset.isNaN || dismissOffset.isInfinite { dismissOffset = 0 }
|
||||
|
||||
let transform = CGAffineTransform.identity
|
||||
.translatedBy(x: currentOffset.x, y: currentOffset.y + dismissOffset)
|
||||
.scaledBy(x: currentScale, y: currentScale)
|
||||
|
||||
if animated {
|
||||
UIView.animate(
|
||||
withDuration: springDuration,
|
||||
delay: 0,
|
||||
usingSpringWithDamping: springDamping,
|
||||
initialSpringVelocity: 0,
|
||||
options: [.curveEaseOut]
|
||||
) {
|
||||
self.imageView.transform = transform
|
||||
}
|
||||
} else {
|
||||
imageView.transform = transform
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pinch Gesture (Centroid Zoom)
|
||||
|
||||
@objc private func handlePinch(_ gesture: UIPinchGestureRecognizer) {
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
pinchStartScale = currentScale
|
||||
pinchStartOffset = currentOffset
|
||||
if gesture.numberOfTouches >= 2 {
|
||||
lastPinchCentroid = gesture.location(in: self)
|
||||
}
|
||||
|
||||
case .changed:
|
||||
let newScale = min(max(pinchStartScale * gesture.scale, minScale * 0.5), maxScale)
|
||||
|
||||
// Centroid-based zoom: keep the point under fingers stationary
|
||||
if gesture.numberOfTouches >= 2 {
|
||||
let centroid = gesture.location(in: self)
|
||||
let viewCenter = CGPoint(x: bounds.midX, y: bounds.midY)
|
||||
let gesturePoint = CGPoint(x: centroid.x - viewCenter.x, y: centroid.y - viewCenter.y)
|
||||
|
||||
let safeCurrentScale = max(currentScale, 0.01)
|
||||
let scaleRatio = newScale / safeCurrentScale
|
||||
guard scaleRatio.isFinite else { break }
|
||||
currentOffset = CGPoint(
|
||||
x: gesturePoint.x - (gesturePoint.x - currentOffset.x) * scaleRatio,
|
||||
y: gesturePoint.y - (gesturePoint.y - currentOffset.y) * scaleRatio
|
||||
)
|
||||
lastPinchCentroid = centroid
|
||||
}
|
||||
|
||||
currentScale = newScale
|
||||
onScaleChanged?(currentScale)
|
||||
applyTransform()
|
||||
|
||||
case .ended, .cancelled:
|
||||
if currentScale < minScale + 0.05 {
|
||||
// Snap back to 1x
|
||||
currentScale = minScale
|
||||
currentOffset = .zero
|
||||
onScaleChanged?(minScale)
|
||||
applyTransform(animated: true)
|
||||
} else {
|
||||
clampOffset(animated: true)
|
||||
}
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pan Gesture (Pan when zoomed, Dismiss when not)
|
||||
|
||||
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
|
||||
let translation = gesture.translation(in: self)
|
||||
let velocity = gesture.velocity(in: self)
|
||||
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
panStartOffset = currentOffset
|
||||
gestureAxisLocked = false
|
||||
isDismissGesture = false
|
||||
|
||||
case .changed:
|
||||
if currentScale > 1.05 {
|
||||
// Zoomed: pan the image
|
||||
currentOffset = CGPoint(
|
||||
x: panStartOffset.x + translation.x,
|
||||
y: panStartOffset.y + translation.y
|
||||
)
|
||||
applyTransform()
|
||||
} else {
|
||||
// Not zoomed: detect axis
|
||||
if !gestureAxisLocked {
|
||||
let dx = abs(translation.x)
|
||||
let dy = abs(translation.y)
|
||||
// Android: abs(panChange.y) > abs(panChange.x) * 1.5
|
||||
if dx > touchSlop || dy > touchSlop {
|
||||
gestureAxisLocked = true
|
||||
isDismissGesture = dy > dx * 1.2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pinch Gesture (zoom to centroid)
|
||||
|
||||
private func pinchGesture(in size: CGSize) -> some Gesture {
|
||||
MagnificationGesture()
|
||||
.onChanged { value in
|
||||
let newScale = min(max(value * (scale > 0.01 ? 1.0 : scale), minScale * 0.5), maxScale)
|
||||
// MagnificationGesture doesn't provide centroid, so zoom to center.
|
||||
// For true centroid zoom, we'd need UIKit gesture recognizers.
|
||||
// This is acceptable — most users don't notice centroid vs center on mobile.
|
||||
scale = newScale
|
||||
}
|
||||
.onEnded { _ in
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
|
||||
if scale < minScale {
|
||||
scale = minScale
|
||||
offset = .zero
|
||||
}
|
||||
clampOffset(in: size)
|
||||
if isDismissGesture {
|
||||
dismissOffset = translation.y
|
||||
let progress = min(abs(dismissOffset) / 300, 1.0)
|
||||
onDismissProgress?(progress)
|
||||
applyTransform()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drag Gesture (pan when zoomed, dismiss when not)
|
||||
|
||||
private func dragGesture(in size: CGSize) -> some Gesture {
|
||||
DragGesture(minimumDistance: touchSlop)
|
||||
.onChanged { value in
|
||||
if scale > 1.05 {
|
||||
// Zoomed: pan image
|
||||
offset = CGSize(
|
||||
width: value.translation.width,
|
||||
height: value.translation.height
|
||||
)
|
||||
case .ended, .cancelled:
|
||||
if currentScale > 1.05 {
|
||||
clampOffset(animated: true)
|
||||
} else if isDismissGesture {
|
||||
let velocityY = abs(velocity.y)
|
||||
if abs(dismissOffset) > dismissDistanceThreshold || velocityY > dismissVelocityThreshold {
|
||||
// Dismiss with fade-out (Android: smoothDismiss 200ms fade)
|
||||
onDismiss?()
|
||||
} else {
|
||||
// Not zoomed: check if vertical dominant (dismiss) or horizontal (page swipe)
|
||||
let dx = abs(value.translation.width)
|
||||
let dy = abs(value.translation.height)
|
||||
if dy > dx * 1.2 {
|
||||
if dismissStartTime == nil {
|
||||
dismissStartTime = Date()
|
||||
}
|
||||
dismissOffset = value.translation.height
|
||||
}
|
||||
// Snap back
|
||||
dismissOffset = 0
|
||||
onDismissCancel?()
|
||||
applyTransform(animated: true)
|
||||
}
|
||||
}
|
||||
.onEnded { value in
|
||||
if scale > 1.05 {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
|
||||
clampOffset(in: size)
|
||||
}
|
||||
} else {
|
||||
// Calculate velocity for dismiss
|
||||
let elapsed = dismissStartTime.map { Date().timeIntervalSince($0) } ?? 0.3
|
||||
let velocityY = abs(dismissOffset) / max(elapsed, 0.01)
|
||||
isDismissGesture = false
|
||||
gestureAxisLocked = false
|
||||
|
||||
if abs(dismissOffset) > dismissDistanceThreshold || velocityY > dismissVelocityThreshold {
|
||||
onDismiss()
|
||||
} else {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
|
||||
dismissOffset = 0
|
||||
}
|
||||
}
|
||||
dismissStartTime = nil
|
||||
}
|
||||
}
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Double Tap (Zoom to tap point)
|
||||
|
||||
@objc private func handleDoubleTap(_ gesture: UITapGestureRecognizer) {
|
||||
let tapPoint = gesture.location(in: self)
|
||||
let viewCenter = CGPoint(x: bounds.midX, y: bounds.midY)
|
||||
|
||||
if currentScale > 1.1 {
|
||||
// Zoom out to 1x
|
||||
currentScale = minScale
|
||||
currentOffset = .zero
|
||||
onScaleChanged?(minScale)
|
||||
applyTransform(animated: true)
|
||||
} else {
|
||||
// Zoom in to tap point at 2.5x (Android: tapX - tapX * targetScale)
|
||||
let tapX = tapPoint.x - viewCenter.x
|
||||
let tapY = tapPoint.y - viewCenter.y
|
||||
|
||||
currentScale = doubleTapScale
|
||||
currentOffset = CGPoint(
|
||||
x: tapX - tapX * doubleTapScale,
|
||||
y: tapY - tapY * doubleTapScale
|
||||
)
|
||||
clampOffsetImmediate()
|
||||
onScaleChanged?(doubleTapScale)
|
||||
applyTransform(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Single Tap (Edge navigation or toggle controls)
|
||||
|
||||
@objc private func handleSingleTap(_ gesture: UITapGestureRecognizer) {
|
||||
guard currentScale <= 1.05 else {
|
||||
// When zoomed, single tap always toggles controls
|
||||
onToggleControls?()
|
||||
return
|
||||
}
|
||||
|
||||
let tapX = gesture.location(in: self).x
|
||||
let width = bounds.width
|
||||
let edgeZone = width * edgeTapFraction
|
||||
|
||||
if tapX < edgeZone {
|
||||
onEdgeTap?(-1) // Previous
|
||||
} else if tapX > width - edgeZone {
|
||||
onEdgeTap?(1) // Next
|
||||
} else {
|
||||
onToggleControls?()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Offset Clamping
|
||||
|
||||
private func clampOffset(in size: CGSize) {
|
||||
guard scale > 1.0 else {
|
||||
offset = .zero
|
||||
private func clampOffset(animated: Bool) {
|
||||
guard currentScale > 1.0 else {
|
||||
currentOffset = .zero
|
||||
applyTransform(animated: animated)
|
||||
return
|
||||
}
|
||||
let maxOffsetX = size.width * (scale - 1) / 2
|
||||
let maxOffsetY = size.height * (scale - 1) / 2
|
||||
offset = CGSize(
|
||||
width: min(max(offset.width, -maxOffsetX), maxOffsetX),
|
||||
height: min(max(offset.height, -maxOffsetY), maxOffsetY)
|
||||
let clamped = clampedOffset()
|
||||
if currentOffset != clamped {
|
||||
currentOffset = clamped
|
||||
applyTransform(animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
private func clampOffsetImmediate() {
|
||||
currentOffset = clampedOffset()
|
||||
}
|
||||
|
||||
private func clampedOffset() -> CGPoint {
|
||||
let maxX = max(bounds.width * (currentScale - 1) / 2, 0)
|
||||
let maxY = max(bounds.height * (currentScale - 1) / 2, 0)
|
||||
return CGPoint(
|
||||
x: min(max(currentOffset.x, -maxX), maxX),
|
||||
y: min(max(currentOffset.y, -maxY), maxY)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - UIGestureRecognizerDelegate
|
||||
|
||||
func gestureRecognizer(
|
||||
_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer
|
||||
) -> Bool {
|
||||
// Allow pinch + pan simultaneously (zoom + drag)
|
||||
let isPinchPan = (gestureRecognizer is UIPinchGestureRecognizer && other is UIPanGestureRecognizer) ||
|
||||
(gestureRecognizer is UIPanGestureRecognizer && other is UIPinchGestureRecognizer)
|
||||
return isPinchPan
|
||||
}
|
||||
|
||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true }
|
||||
let velocity = pan.velocity(in: self)
|
||||
|
||||
if currentScale <= 1.05 {
|
||||
// Not zoomed: only begin for vertical-dominant drags.
|
||||
// Let horizontal swipes pass through to TabView for paging.
|
||||
return abs(velocity.y) > abs(velocity.x) * 1.2
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,6 +564,7 @@ private struct ChatListDialogContent: View {
|
||||
@State private var typingDialogs: Set<String> = []
|
||||
|
||||
var body: some View {
|
||||
let _ = PerformanceLogger.shared.track("chatList.bodyEval")
|
||||
// Use pre-partitioned arrays from ViewModel (single-pass O(n) instead of 3× filter).
|
||||
let pinned = viewModel.allModePinned
|
||||
let unpinned = viewModel.allModeUnpinned
|
||||
|
||||
@@ -34,6 +34,7 @@ struct ChatRowView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let _ = PerformanceLogger.shared.track("chatRow.bodyEval")
|
||||
HStack(spacing: 0) {
|
||||
avatarSection
|
||||
.padding(.trailing, 10)
|
||||
|
||||
Reference in New Issue
Block a user