From 4d7dd826ad943797ede3f5fd7656e076f4febb6e Mon Sep 17 00:00:00 2001 From: senseiGai Date: Fri, 17 Apr 2026 16:13:05 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D0=BD=20=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D1=82=20Message=20=D1=83=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D0=BB=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B3=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D1=81=D0=BE=D0=B2=D1=8B=D1=85=20+=20=D1=88=D0=B8?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B0=20bubble=20=D0=BF=D0=BE=20duration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Rosetta.xcodeproj/project.pbxproj | 12 ++-- .../xcshareddata/xcschemes/Rosetta.xcscheme | 2 +- Rosetta/Core/Layout/MessageCellLayout.swift | 57 ++++++++++++----- .../ChatDetail/ChatDetailViewController.swift | 11 ++++ .../ChatDetail/ChatDetailViewModel.swift | 12 ++++ .../Chats/ChatDetail/MessageVoiceView.swift | 16 ++++- .../Chats/ChatDetail/NativeMessageCell.swift | 24 +++++--- .../Chats/ChatDetail/NativeMessageList.swift | 61 ++++++++++++++----- .../Chats/ChatList/UIKit/ChatListCell.swift | 48 +++++++++------ 9 files changed, 178 insertions(+), 65 deletions(-) diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index b22af0a..843a01e 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -949,7 +949,7 @@ C19929D9466573F31997B2C0 /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Debug; }; 853F295D2F4B50410092AD05 /* Build configuration list for PBXProject "Rosetta" */ = { isa = XCConfigurationList; @@ -958,7 +958,7 @@ 853F296C2F4B50420092AD05 /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Debug; }; 853F296D2F4B50420092AD05 /* Build configuration list for PBXNativeTarget "Rosetta" */ = { isa = XCConfigurationList; @@ -967,7 +967,7 @@ 853F296F2F4B50420092AD05 /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Debug; }; A8D200712F9000010092AD05 /* Build configuration list for PBXNativeTarget "RosettaUITests" */ = { isa = XCConfigurationList; @@ -976,7 +976,7 @@ A8D200622F9000010092AD05 /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Debug; }; B5D2E60ADEB8AE2E8F7615C6 /* Build configuration list for PBXNativeTarget "RosettaNotificationService" */ = { isa = XCConfigurationList; @@ -985,7 +985,7 @@ 0140D6320A9CF4B5E933E0B1 /* Debug */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Debug; }; LA00000062F8D22220092AD05 /* Build configuration list for PBXNativeTarget "RosettaLiveActivityWidget" */ = { isa = XCConfigurationList; @@ -994,7 +994,7 @@ LA00000082F8D22220092AD05 /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Debug; }; /* End XCConfigurationList section */ diff --git a/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme b/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme index a4e863c..39a5209 100644 --- a/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme +++ b/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme @@ -66,7 +66,7 @@ (layouts: [String: MessageCellLayout], textLayouts: [String: CoreTextTextLayout]) { var result: [String: MessageCellLayout] = existingLayouts ?? [:] var textResult: [String: CoreTextTextLayout] = existingTextLayouts ?? [:] - let timestampFormatter = DateFormatter() - timestampFormatter.dateFormat = "HH:mm" - timestampFormatter.locale = .autoupdatingCurrent - timestampFormatter.timeZone = .autoupdatingCurrent let calendar = Calendar.current let now = Date() - let sameYearFormatter = DateFormatter() - sameYearFormatter.dateFormat = "MMMM d" - sameYearFormatter.locale = .autoupdatingCurrent - let diffYearFormatter = DateFormatter() - diffYearFormatter.dateFormat = "MMMM d, yyyy" - diffYearFormatter.locale = .autoupdatingCurrent for (index, message) in messages.enumerated() { // Incremental: skip messages not in dirty set (reuse existing cache) @@ -1219,7 +1235,7 @@ extension MessageCellLayout { } let isOutgoing = message.fromPublicKey == currentPublicKey let messageDate = Date(timeIntervalSince1970: TimeInterval(message.timestamp) / 1000) - let timestampText = timestampFormatter.string(from: messageDate) + let timestampText = Self.timestampFmt.string(from: messageDate) // Date header: show on first message of each calendar day let showsDateHeader: Bool @@ -1231,8 +1247,8 @@ extension MessageCellLayout { } let dateHeaderText = showsDateHeader ? Self.formatDateHeader(messageDate, now: now, calendar: calendar, - sameYearFormatter: sameYearFormatter, - diffYearFormatter: diffYearFormatter) + sameYearFormatter: Self.sameYearHeaderFmt, + diffYearFormatter: Self.diffYearHeaderFmt) : "" // Filter garbage/encrypted text (UIKit path parity with SwiftUI MessageCellView) @@ -1298,6 +1314,7 @@ extension MessageCellLayout { var forwardInnerImageCount = 0 var forwardInnerFileCount = 0 var forwardInnerVoiceCount = 0 + var forwardVoiceDuration: TimeInterval = 0 var forwardSenderName = "" var forwardChachaKeyPlain = "" var forwardAttachments: [ReplyAttachmentData] = [] @@ -1314,6 +1331,18 @@ extension MessageCellLayout { forwardInnerImageCount = first.attachments.filter { $0.type == 0 }.count forwardInnerFileCount = first.attachments.filter { $0.type == 2 }.count forwardInnerVoiceCount = first.attachments.filter { $0.type == 5 }.count + // Suppress caption for voice-only forwards (Telegram parity) + if forwardInnerVoiceCount > 0 && forwardInnerImageCount == 0 && forwardInnerFileCount == 0 { + forwardCaption = nil + } + // Parse forward voice duration for bubble width scaling + if forwardInnerVoiceCount > 0, voiceDuration == 0, + let fwdVoicePreview = first.attachments.first(where: { $0.type == 5 })?.preview { + let parts = fwdVoicePreview.components(separatedBy: "::") + if parts.count >= 3, let dur = Int(parts[1]) { forwardVoiceDuration = TimeInterval(dur) } + else if parts.count >= 2, let dur = Int(parts[0]) { forwardVoiceDuration = TimeInterval(dur) } + else if let dur = Int(parts[0]) { forwardVoiceDuration = TimeInterval(dur) } + } forwardChachaKeyPlain = first.chacha_key_plain forwardAttachments = first.attachments // Resolve forward sender name for dynamic bubble width @@ -1394,7 +1423,7 @@ extension MessageCellLayout { avatarCount: avatars.count, callCount: calls.count, voiceCount: voices.count, - voiceDuration: voiceDuration, + voiceDuration: voiceDuration > 0 ? voiceDuration : forwardVoiceDuration, isForward: isForward, forwardImageCount: forwardInnerImageCount, forwardFileCount: forwardInnerFileCount, diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift index 310a789..8baf52f 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift @@ -1,6 +1,7 @@ import UIKit import Combine import SwiftUI +import os // MARK: - ChatDetailViewController @@ -9,6 +10,8 @@ import SwiftUI /// Phase 1: shell + child VC + state + callbacks + Combine subscriptions. final class ChatDetailViewController: UIViewController { + private static let perfLog = Logger(subsystem: "com.rosetta.messenger", category: "ChatOpen") + // MARK: - Route & ViewModel let route: ChatRoute @@ -104,6 +107,7 @@ final class ChatDetailViewController: UIViewController { // MARK: - Lifecycle override func viewDidLoad() { + let vdlStart = CACurrentMediaTime() super.viewDidLoad() view.backgroundColor = UIColor { $0.userInterfaceStyle == .dark ? .black : .white } @@ -119,6 +123,8 @@ final class ChatDetailViewController: UIViewController { setupEdgeEffects() wireCellActions() wireViewModelSubscriptions() + let vdlMs = (CACurrentMediaTime() - vdlStart) * 1000 + Self.perfLog.info("⚡ viewDidLoad \(String(format: "%.0f", vdlMs))ms | msgs=\(self.viewModel.messages.count)") // Show forward preview bar if VC was opened with pending forward data if pendingForwardData != nil { @@ -259,9 +265,14 @@ final class ChatDetailViewController: UIViewController { // skeleton flash even when messages are cached. let initialMessages = viewModel.messages.filter { !$0.text.hasPrefix("$a=") } if !initialMessages.isEmpty { + let seedStart = CACurrentMediaTime() controller.updateEmptyState(isEmpty: false, info: makeEmptyChatInfo()) controller.update(messages: initialMessages) lastMessageFingerprint = messageFingerprint(viewModel.messages) + let seedMs = (CACurrentMediaTime() - seedStart) * 1000 + Self.perfLog.info("⚡ seed \(initialMessages.count) msgs → update() \(String(format: "%.0f", seedMs))ms") + } else { + Self.perfLog.info("⚡ seed: no cached msgs → skeleton") } // Fix date pill sticky offset for floating header diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift index 48f3c50..1333f45 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift @@ -1,5 +1,7 @@ import Foundation import Combine +import os +import QuartzCore /// Per-dialog observation isolation for ChatDetailView. /// @@ -28,15 +30,25 @@ final class ChatDetailViewModel: ObservableObject { private var cancellables = Set() + private static let perfLog = Logger(subsystem: "com.rosetta.messenger", category: "ChatOpen") + init(dialogKey: String) { self.dialogKey = dialogKey let repo = MessageRepository.shared + // Reset cache to latest messages — prevents stale scrolled-up window from previous visit. + // reloadLatest loads maxCacheSize (200) msgs from DB sync (~60ms). + // Hidden behind push transition animation (350ms). + repo.reloadLatest(for: dialogKey) + // Seed with current values + let vmStart = CACurrentMediaTime() let initial = repo.messages(for: dialogKey) messages = initial isTyping = repo.isTyping(dialogKey: dialogKey) + let vmMs = (CACurrentMediaTime() - vmStart) * 1000 + Self.perfLog.info("⚡ ViewModel.init: \(initial.count) cached msgs loaded in \(String(format: "%.1f", vmMs))ms") // Android parity: if we already have messages, skip skeleton. // Otherwise keep isLoading=true until first Combine emission or timeout. isLoading = initial.isEmpty diff --git a/Rosetta/Features/Chats/ChatDetail/MessageVoiceView.swift b/Rosetta/Features/Chats/ChatDetail/MessageVoiceView.swift index 8b859a5..886459a 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageVoiceView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageVoiceView.swift @@ -136,6 +136,9 @@ final class MessageVoiceView: UIView { width: max(0, waveW), height: waveformHeight ) + // Force redraw after layout — frame change alone may not trigger display + // when the size stays the same between reuse cycles. + waveformView.setNeedsDisplay() // Duration: at x=56, y=22 durationLabel.frame = CGRect( @@ -164,8 +167,17 @@ final class MessageVoiceView: UIView { self.totalDuration = duration - // Decode waveform from preview - let samples = Self.decodeWaveform(from: preview) + // Decode waveform from preview — fallback to flat bars if empty + var samples = Self.decodeWaveform(from: preview) + #if DEBUG + if samples.isEmpty { + print("[VOICE_WAVEFORM] empty waveform — msgId=\(messageId.prefix(8))… preview='\(preview.prefix(80))'") + } + #endif + if samples.isEmpty { + // Telegram shows flat bars when waveform data is missing (better than empty space) + samples = [Float](repeating: 0.3, count: 30) + } waveformView.setSamples(samples) waveformView.progress = 0 diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index 1269df0..b5b4c98 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -1872,15 +1872,25 @@ final class NativeMessageCell: UICollectionViewCell { /// Parse voice preview: "tag::duration::waveform" or "duration::waveform" static func parseVoicePreview(_ preview: String) -> (duration: TimeInterval, waveform: String) { let parts = preview.components(separatedBy: "::") - // Format: "tag::duration::waveform" or "duration::waveform" - if parts.count >= 3, let dur = Int(parts[1]) { - return (TimeInterval(dur), parts[2]) - } else if parts.count >= 2, let dur = Int(parts[0]) { - return (TimeInterval(dur), parts[1]) - } else if let dur = Int(parts[0]) { + // Format: "tag::duration::waveform" or "duration::waveform" or "::duration::waveform" + // Try 3+ parts: tag::duration::waveform (or leading empty ::duration::waveform) + if parts.count >= 3 { + // Check parts[1] first (tag::dur::wave), then parts[0] if that fails (edge cases) + if let dur = Int(parts[1]) { + return (TimeInterval(dur), parts.dropFirst(2).joined(separator: "::")) + } else if let dur = Int(parts[0]) { + return (TimeInterval(dur), parts.dropFirst(1).joined(separator: "::")) + } + } + // 2 parts: duration::waveform + if parts.count >= 2, let dur = Int(parts[0]) { + return (TimeInterval(dur), parts.dropFirst(1).joined(separator: "::")) + } + // Single part: just duration (no waveform) + if let dur = Int(parts[0]) { return (TimeInterval(dur), "") } - return (0, preview) + return (0, "") } private static func resolvePlayableVoiceURL( diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index eb5c6fd..e3cc641 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -1,4 +1,5 @@ import Combine +import os import SwiftUI import UIKit @@ -23,6 +24,8 @@ struct ReplyDataCacheEntry { @MainActor final class NativeMessageListController: UIViewController { + private static let perfLog = Logger(subsystem: "com.rosetta.messenger", category: "ChatOpen") + private enum UIConstants { static let messageToComposerGap: CGFloat = 16 static let scrollButtonSize: CGFloat = 40 @@ -1498,10 +1501,9 @@ final class NativeMessageListController: UIViewController { : [] if isInteractive { for ip in collectionView.indexPathsForVisibleItems { - if let msgId = messageId(for: ip), - let cell = collectionView.cellForItem(at: ip) { - oldPositions[msgId] = cell.layer.position.y - } + guard let itemId = dataSource.itemIdentifier(for: ip), + let cell = collectionView.cellForItem(at: ip) else { continue } + oldPositions[itemId] = cell.layer.position.y } } @@ -1527,8 +1529,26 @@ final class NativeMessageListController: UIViewController { // Interactive inserts (≤3 new messages) MUST be sync to avoid delayed // reconfigureVisibleCells() from calculateLayoutsAsync killing animations. if layoutCache.isEmpty { - // First load: synchronous to avoid blank cells - calculateLayouts() + // First load: sync-calculate only the visible tail (newest messages + // shown at bottom of inverted list). 25 covers ~15 visible + scroll buffer. + // Remaining layouts computed async via LayoutEngine actor. + let syncCount = min(messages.count, 25) + if syncCount < messages.count { + let tailMessages = Array(messages.suffix(syncCount)) + let layoutStart = CACurrentMediaTime() + calculateLayouts(messageSubset: tailMessages) + let layoutMs = (CACurrentMediaTime() - layoutStart) * 1000 + Self.perfLog.info("⚡ layoutSync: \(syncCount)/\(self.messages.count) msgs in \(String(format: "%.0f", layoutMs))ms (windowed)") + // Async: full array with correct neighbor context for BubblePosition. + // Overwrites the sync-computed layouts (fixes boundary BubblePosition). + calculateLayoutsAsync() + } else { + // ≤25 messages — sync all (fast enough, <75ms) + let layoutStart = CACurrentMediaTime() + calculateLayouts() + let layoutMs = (CACurrentMediaTime() - layoutStart) * 1000 + Self.perfLog.info("⚡ layoutSync: \(self.messages.count)/\(self.messages.count) msgs in \(String(format: "%.0f", layoutMs))ms (all)") + } } else if isInteractive { // Interactive insert (1-3 messages): sync layout so no delayed reconfigure var dirtyIds = newIds @@ -1537,17 +1557,16 @@ final class NativeMessageListController: UIViewController { if i < messages.count - 1 { dirtyIds.insert(messages[i + 1].id) } } calculateLayouts(dirtyIds: dirtyIds) - } else if !newIds.isEmpty && newIds.count <= 20 { - // Incremental non-interactive: async on background + } else if !newIds.isEmpty { + // Non-interactive update (pagination, sync): sync layout for new messages + neighbors. + // Cells MUST have layouts before snapshot — async leaves them empty (race condition). + // ~52 CoreText measurements ≈ 30-50ms. Cache bounded by maxCacheSize (200). var dirtyIds = newIds for i in messages.indices where newIds.contains(messages[i].id) { if i > 0 { dirtyIds.insert(messages[i - 1].id) } if i < messages.count - 1 { dirtyIds.insert(messages[i + 1].id) } } - calculateLayoutsAsync(dirtyIds: dirtyIds) - } else if !newIds.isEmpty { - // Bulk update (pagination, sync): async full recalculation - calculateLayoutsAsync() + calculateLayouts(dirtyIds: dirtyIds) } // else: newIds is empty — no new messages, skip layout recalculation. // Prevents Combine debounce duplicate from killing insertion animations @@ -1591,7 +1610,12 @@ final class NativeMessageListController: UIViewController { } } + let snapStart = CACurrentMediaTime() dataSource.apply(snapshot, animatingDifferences: false) + if !hasCompletedInitialLoad { + let snapMs = (CACurrentMediaTime() - snapStart) * 1000 + Self.perfLog.info("⚡ snapshot.apply: \(itemIds.count) items in \(String(format: "%.0f", snapMs))ms") + } // Voice send correlation: fire deferred collapse when the voice cell appears. // Handled AFTER layout settles (below), regardless of isInteractive. @@ -1668,7 +1692,6 @@ final class NativeMessageListController: UIViewController { private func applyInsertionAnimations(newIds: Set, oldPositions: [String: CGFloat]) { for ip in collectionView.indexPathsForVisibleItems { guard let cellId = dataSource.itemIdentifier(for: ip), - cellId != Self.unreadSeparatorId, let cell = collectionView.cellForItem(at: ip) else { continue } if newIds.contains(cellId) { @@ -1702,15 +1725,17 @@ final class NativeMessageListController: UIViewController { /// Recalculate layouts for messages. When `dirtyIds` is provided, only those /// messages are recalculated (incremental mode). Otherwise recalculates all. - private func calculateLayouts(dirtyIds: Set? = nil) { - guard !messages.isEmpty else { + /// `messageSubset` overrides the message array (used for windowed initial sync). + private func calculateLayouts(dirtyIds: Set? = nil, messageSubset: [ChatMessage]? = nil) { + let msgs = messageSubset ?? messages + guard !msgs.isEmpty else { layoutCache.removeAll() textLayoutCache.removeAll() return } let isDark = cachedIsDarkMode let (layouts, textLayouts) = MessageCellLayout.batchCalculate( - messages: messages, + messages: msgs, maxBubbleWidth: config.maxBubbleWidth, currentPublicKey: config.currentPublicKey, opponentPublicKey: config.opponentPublicKey, @@ -1750,8 +1775,12 @@ final class NativeMessageListController: UIViewController { existingTextLayouts: dirtyIds != nil ? textLayoutCache : nil ) + let asyncStart = CACurrentMediaTime() + let msgCount = messages.count Task { @MainActor [weak self] in let result = await LayoutEngine.shared.calculate(request) + let asyncMs = (CACurrentMediaTime() - asyncStart) * 1000 + Self.perfLog.info("⚡ layoutAsync: \(msgCount) msgs in \(String(format: "%.0f", asyncMs))ms (background)") guard let self, result.generation >= self.layoutGeneration else { return } self.layoutGeneration = result.generation self.layoutCache = result.layouts diff --git a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift index 4a9b6ce..a46748a 100644 --- a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift +++ b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift @@ -676,8 +676,8 @@ final class ChatListCell: UICollectionViewCell { animateBadgeTransition(view: mentionImageView, shouldShow: showMention, wasVisible: &wasMentionBadgeVisible) } - /// Telegram badge animation: appear = scale 0.0001→1.2 (0.2s) → 1.0 (0.12s settle); - /// disappear = scale 1.0→0.0001 (0.12s). Uses transform (not frame) + .allowUserInteraction. + /// Telegram badge animation: appear = CASpringAnimation pop-in; disappear = scale down. + /// Uses Core Animation (not UIView.animate) so animations work inside performWithoutAnimation. private func animateBadgeTransition(view: UIView, shouldShow: Bool, wasVisible: inout Bool) { let wasShowing = wasVisible wasVisible = shouldShow @@ -686,26 +686,36 @@ final class ChatListCell: UICollectionViewCell { view.layer.removeAllAnimations() if shouldShow && !wasShowing { - // Appear: pop in with bounce + // Appear: spring pop-in (CASpringAnimation bypasses performWithoutAnimation) view.isHidden = false - view.transform = CGAffineTransform(scaleX: 0.0001, y: 0.0001) - UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseOut, .allowUserInteraction]) { - view.transform = CGAffineTransform(scaleX: 1.15, y: 1.15) - } completion: { _ in - UIView.animate(withDuration: 0.12, delay: 0, options: [.curveEaseInOut, .allowUserInteraction]) { - view.transform = .identity - } - } + view.transform = .identity + + let spring = CASpringAnimation(keyPath: "transform.scale") + spring.fromValue = 0.0001 + spring.toValue = 1.0 + spring.mass = 1.0 + spring.stiffness = 400 + spring.damping = 22 + spring.duration = spring.settlingDuration + view.layer.add(spring, forKey: "badgePop") } else if !shouldShow && wasShowing { - // Disappear: scale down - UIView.animate(withDuration: 0.12, delay: 0, options: [.curveEaseIn, .allowUserInteraction]) { - view.transform = CGAffineTransform(scaleX: 0.0001, y: 0.0001) - } completion: { finished in - if finished { - view.isHidden = true - view.transform = .identity - } + // Disappear: quick scale down + let anim = CABasicAnimation(keyPath: "transform.scale") + anim.fromValue = 1.0 + anim.toValue = 0.0001 + anim.duration = 0.12 + anim.timingFunction = CAMediaTimingFunction(name: .easeIn) + anim.fillMode = .forwards + anim.isRemovedOnCompletion = false + + CATransaction.begin() + CATransaction.setCompletionBlock { [weak view] in + view?.isHidden = true + view?.transform = .identity + view?.layer.removeAnimation(forKey: "badgeHide") } + view.layer.add(anim, forKey: "badgeHide") + CATransaction.commit() } else { // No transition — just set visibility view.isHidden = !shouldShow