Оптимизация FPS чата: ForEach fast path, keyboard animation без updateUIView, debounce pipeline, кэши с half-eviction, release notes механизм

This commit is contained in:
2026-03-19 03:35:04 +05:00
parent 422b20702e
commit 44652e0d97
21 changed files with 1349 additions and 318 deletions

View File

@@ -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)
}
}
}

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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)
}
}
}

View File

@@ -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
}

View File

@@ -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 (1x5x)
/// - 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
}
}

View File

@@ -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

View File

@@ -34,6 +34,7 @@ struct ChatRowView: View {
}
var body: some View {
let _ = PerformanceLogger.shared.track("chatRow.bodyEval")
HStack(spacing: 0) {
avatarSection
.padding(.trailing, 10)