Реализован медиа-коллаж telegram-like, с переполнением и распределением статусов по уровням.

This commit is contained in:
2026-03-28 00:40:35 +05:00
parent e03e3685e7
commit d706ef6d62
3 changed files with 429 additions and 126 deletions

View File

@@ -128,7 +128,10 @@ struct ChatDetailView: View {
private var maxBubbleWidth: CGFloat {
let w = UIScreen.main.bounds.width
return w <= 500 ? w - 36 : w * 0.85
if w <= 500 {
return max(240, min(w * 0.78, w - 86))
}
return min(w * 0.72, 520)
}
/// Visual chat content: messages list + gradient overlays + background.

View File

@@ -23,7 +23,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private static let forwardNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold)
private static let fileNameFont = UIFont.systemFont(ofSize: 14, weight: .medium)
private static let fileSizeFont = UIFont.systemFont(ofSize: 12, weight: .regular)
private static let statusBubbleInsets = UIEdgeInsets(top: 2, left: 7, bottom: 2, right: 7)
private static let statusBubbleInsets = UIEdgeInsets(top: 3, left: 7, bottom: 3, right: 7)
private static let sendingClockAnimationKey = "clockFrameAnimation"
// MARK: - Telegram Check Images (CGContext ported from PresentationThemeEssentialGraphics.swift)
@@ -127,6 +127,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private static let mediaClockFrameImage = generateTelegramClockFrame(color: mediaMetaColor)
private static let mediaClockMinImage = generateTelegramClockMin(color: mediaMetaColor)
private static let errorIcon = generateErrorIcon(color: .systemRed)
private static let maxVisiblePhotoTiles = 5
private static let blurHashCache: NSCache<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
cache.countLimit = 200
@@ -157,10 +158,14 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private let replyNameLabel = UILabel()
private let replyTextLabel = UILabel()
// Photo
private let photoView = UIImageView()
private let photoPlaceholderView = UIView()
private let photoActivityIndicator = UIActivityIndicatorView(style: .medium)
// Photo collage (up to 5 tiles)
private let photoContainer = UIView()
private var photoTileImageViews: [UIImageView] = []
private var photoTilePlaceholderViews: [UIView] = []
private var photoTileActivityIndicators: [UIActivityIndicatorView] = []
private var photoTileButtons: [UIButton] = []
private let photoOverflowOverlayView = UIView()
private let photoOverflowLabel = UILabel()
// File
private let fileContainer = UIView()
@@ -185,10 +190,11 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private var isDeliveryFailedVisible = false
private var wasSentCheckVisible = false
private var wasReadCheckVisible = false
private var photoAttachmentId: String?
private var photoLoadTask: Task<Void, Never>?
private var photoDownloadTask: Task<Void, Never>?
private var isPhotoDownloading = false
private var photoAttachments: [MessageAttachment] = []
private var totalPhotoAttachmentCount = 0
private var photoLoadTasks: [String: Task<Void, Never>] = [:]
private var photoDownloadTasks: [String: Task<Void, Never>] = [:]
private var downloadingAttachmentIds: Set<String> = []
// MARK: - Init
@@ -252,21 +258,53 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
replyContainer.addSubview(replyTextLabel)
bubbleView.addSubview(replyContainer)
// Photo
photoView.contentMode = .scaleAspectFill
photoView.clipsToBounds = true
photoView.isUserInteractionEnabled = true
bubbleView.addSubview(photoView)
// Photo collage
photoContainer.backgroundColor = .clear
photoContainer.clipsToBounds = true
bubbleView.addSubview(photoContainer)
photoPlaceholderView.backgroundColor = UIColor.white.withAlphaComponent(0.1)
bubbleView.addSubview(photoPlaceholderView)
for index in 0..<Self.maxVisiblePhotoTiles {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.isHidden = true
photoContainer.addSubview(imageView)
photoActivityIndicator.color = .white
photoActivityIndicator.hidesWhenStopped = true
bubbleView.addSubview(photoActivityIndicator)
let placeholderView = UIView()
placeholderView.backgroundColor = UIColor.white.withAlphaComponent(0.1)
placeholderView.isHidden = true
photoContainer.addSubview(placeholderView)
let photoTap = UITapGestureRecognizer(target: self, action: #selector(handlePhotoTap))
photoView.addGestureRecognizer(photoTap)
let indicator = UIActivityIndicatorView(style: .medium)
indicator.color = .white
indicator.hidesWhenStopped = true
indicator.isHidden = true
photoContainer.addSubview(indicator)
let button = UIButton(type: .custom)
button.tag = index
button.isHidden = true
button.addTarget(self, action: #selector(handlePhotoTileTap(_:)), for: .touchUpInside)
photoContainer.addSubview(button)
photoTileImageViews.append(imageView)
photoTilePlaceholderViews.append(placeholderView)
photoTileActivityIndicators.append(indicator)
photoTileButtons.append(button)
}
photoOverflowOverlayView.backgroundColor = UIColor.black.withAlphaComponent(0.45)
photoOverflowOverlayView.layer.cornerCurve = .continuous
photoOverflowOverlayView.layer.cornerRadius = 0
photoOverflowOverlayView.isHidden = true
photoOverflowOverlayView.isUserInteractionEnabled = false
photoContainer.addSubview(photoOverflowOverlayView)
photoOverflowLabel.font = UIFont.systemFont(ofSize: 26, weight: .semibold)
photoOverflowLabel.textColor = .white
photoOverflowLabel.textAlignment = .center
photoOverflowLabel.isHidden = true
photoOverflowOverlayView.addSubview(photoOverflowLabel)
// File
fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
@@ -388,6 +426,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
// Bubble color
bubbleLayer.fillColor = (isOutgoing ? Self.outgoingColor : Self.incomingColor).cgColor
photoContainer.backgroundColor = isOutgoing ? Self.outgoingColor : Self.incomingColor
// Reply quote
if let replyName {
@@ -511,13 +550,12 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
}
// Photo
photoView.isHidden = !layout.hasPhoto
photoPlaceholderView.isHidden = !layout.hasPhoto
photoContainer.isHidden = !layout.hasPhoto
if layout.hasPhoto {
photoView.frame = layout.photoFrame
photoPlaceholderView.frame = layout.photoFrame
photoActivityIndicator.center = CGPoint(x: layout.photoFrame.midX, y: layout.photoFrame.midY)
photoContainer.frame = layout.photoFrame
layoutPhotoTiles()
}
bringStatusOverlayToFront()
// File
fileContainer.isHidden = !layout.hasFile
@@ -655,15 +693,14 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
actions.onRetry(message)
}
@objc private func handlePhotoTap() {
guard let message,
let actions,
let layout = currentLayout,
layout.hasPhoto,
let attachment = message.attachments.first(where: { $0.type == .image }) else {
@objc private func handlePhotoTileTap(_ sender: UIButton) {
guard sender.tag >= 0, sender.tag < photoAttachments.count,
let message,
let actions else {
return
}
let attachment = photoAttachments[sender.tag]
if AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) != nil {
actions.onImageTap(attachment.id)
return
@@ -674,58 +711,253 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private func configurePhoto(for message: ChatMessage) {
guard let layout = currentLayout, layout.hasPhoto else {
photoAttachmentId = nil
photoLoadTask?.cancel()
photoLoadTask = nil
photoDownloadTask?.cancel()
photoDownloadTask = nil
isPhotoDownloading = false
photoActivityIndicator.stopAnimating()
photoView.image = nil
photoView.isHidden = true
photoPlaceholderView.isHidden = true
resetPhotoTiles()
return
}
guard let attachment = message.attachments.first(where: { $0.type == .image }) else {
photoAttachmentId = nil
photoLoadTask?.cancel()
photoLoadTask = nil
photoDownloadTask?.cancel()
photoDownloadTask = nil
isPhotoDownloading = false
photoActivityIndicator.stopAnimating()
photoView.image = nil
photoView.isHidden = true
photoPlaceholderView.isHidden = false
let allPhotoAttachments = message.attachments.filter { $0.type == .image }
totalPhotoAttachmentCount = allPhotoAttachments.count
photoAttachments = Array(allPhotoAttachments.prefix(Self.maxVisiblePhotoTiles))
guard !photoAttachments.isEmpty else {
resetPhotoTiles()
return
}
photoAttachmentId = attachment.id
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
photoView.image = cached
photoView.isHidden = false
photoPlaceholderView.isHidden = true
photoActivityIndicator.stopAnimating()
isPhotoDownloading = false
photoLoadTask?.cancel()
photoLoadTask = nil
return
let activeIds = Set(photoAttachments.map(\.id))
for (attachmentId, task) in photoLoadTasks where !activeIds.contains(attachmentId) {
task.cancel()
photoLoadTasks.removeValue(forKey: attachmentId)
}
for (attachmentId, task) in photoDownloadTasks where !activeIds.contains(attachmentId) {
task.cancel()
photoDownloadTasks.removeValue(forKey: attachmentId)
downloadingAttachmentIds.remove(attachmentId)
}
photoView.image = Self.blurHashImage(from: attachment.preview)
photoView.isHidden = false
photoPlaceholderView.isHidden = photoView.image != nil
if !isPhotoDownloading {
photoActivityIndicator.stopAnimating()
for index in 0..<photoTileImageViews.count {
let isActiveTile = index < photoAttachments.count
let imageView = photoTileImageViews[index]
let placeholderView = photoTilePlaceholderViews[index]
let indicator = photoTileActivityIndicators[index]
let button = photoTileButtons[index]
button.isHidden = !isActiveTile
guard isActiveTile else {
imageView.image = nil
imageView.isHidden = true
placeholderView.isHidden = true
indicator.stopAnimating()
indicator.isHidden = true
continue
}
let attachment = photoAttachments[index]
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
setPhotoTileImage(cached, at: index, animated: false)
placeholderView.isHidden = true
indicator.stopAnimating()
indicator.isHidden = true
} else {
setPhotoTileImage(Self.blurHashImage(from: attachment.preview), at: index, animated: false)
placeholderView.isHidden = imageView.image != nil
if downloadingAttachmentIds.contains(attachment.id) {
indicator.startAnimating()
indicator.isHidden = false
} else {
indicator.stopAnimating()
indicator.isHidden = true
}
startPhotoLoadTask(attachment: attachment)
}
}
startPhotoLoadTask(attachmentId: attachment.id)
layoutPhotoOverflowOverlay()
}
private func startPhotoLoadTask(attachmentId: String) {
photoLoadTask?.cancel()
photoLoadTask = Task { [weak self] in
private func layoutPhotoTiles() {
guard !photoAttachments.isEmpty else { return }
updatePhotoContainerMask()
let frames = Self.photoTileFrames(count: photoAttachments.count, in: photoContainer.bounds)
for (index, frame) in frames.enumerated() where index < photoTileImageViews.count {
photoTileImageViews[index].frame = frame
photoTilePlaceholderViews[index].frame = frame
photoTileButtons[index].frame = frame
photoTileActivityIndicators[index].center = CGPoint(x: frame.midX, y: frame.midY)
}
layoutPhotoOverflowOverlay(frames: frames)
}
private func updatePhotoContainerMask() {
guard let layout = currentLayout else {
photoContainer.layer.mask = nil
return
}
let inset: CGFloat = 2
let r: CGFloat = max(16 - inset, 0)
let s: CGFloat = max(8 - inset, 0)
let rect = photoContainer.bounds
let (tl, tr, bl, br): (CGFloat, CGFloat, CGFloat, CGFloat) = {
switch layout.position {
case .single: return (r, r, r, r)
case .top: return layout.isOutgoing ? (r, r, r, s) : (r, r, s, r)
case .mid: return layout.isOutgoing ? (r, s, r, s) : (s, r, s, r)
case .bottom: return layout.isOutgoing ? (r, s, r, r) : (s, r, r, r)
}
}()
let maxR = min(rect.width, rect.height) / 2
let cTL = min(tl, maxR), cTR = min(tr, maxR)
let cBL = min(bl, maxR), cBR = min(br, maxR)
let path = UIBezierPath()
path.move(to: CGPoint(x: rect.minX + cTL, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX - cTR, y: rect.minY))
path.addArc(withCenter: CGPoint(x: rect.maxX - cTR, y: rect.minY + cTR),
radius: cTR, startAngle: -.pi/2, endAngle: 0, clockwise: true)
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - cBR))
path.addArc(withCenter: CGPoint(x: rect.maxX - cBR, y: rect.maxY - cBR),
radius: cBR, startAngle: 0, endAngle: .pi/2, clockwise: true)
path.addLine(to: CGPoint(x: rect.minX + cBL, y: rect.maxY))
path.addArc(withCenter: CGPoint(x: rect.minX + cBL, y: rect.maxY - cBL),
radius: cBL, startAngle: .pi/2, endAngle: .pi, clockwise: true)
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + cTL))
path.addArc(withCenter: CGPoint(x: rect.minX + cTL, y: rect.minY + cTL),
radius: cTL, startAngle: .pi, endAngle: -.pi/2, clockwise: true)
path.close()
let mask = CAShapeLayer()
mask.frame = rect
mask.path = path.cgPath
photoContainer.layer.mask = mask
}
private static func photoTileFrames(count: Int, in bounds: CGRect) -> [CGRect] {
let spacing: CGFloat = 2
let width = bounds.width
let height = bounds.height
guard count > 0, width > 0, height > 0 else { return [] }
switch count {
case 1:
return [bounds]
case 2:
let cellWidth = (width - spacing) / 2
return [
CGRect(x: 0, y: 0, width: cellWidth, height: height),
CGRect(x: cellWidth + spacing, y: 0, width: cellWidth, height: height)
]
case 3:
let rightWidth = width * 0.34
let leftWidth = width - spacing - rightWidth
let rightTopHeight = (height - spacing) / 2
let rightBottomHeight = height - rightTopHeight - spacing
return [
CGRect(x: 0, y: 0, width: leftWidth, height: height),
CGRect(x: leftWidth + spacing, y: 0, width: rightWidth, height: rightTopHeight),
CGRect(x: leftWidth + spacing, y: rightTopHeight + spacing, width: rightWidth, height: rightBottomHeight)
]
case 4:
let cellWidth = (width - spacing) / 2
let topHeight = (height - spacing) / 2
let bottomHeight = height - topHeight - spacing
return [
CGRect(x: 0, y: 0, width: cellWidth, height: topHeight),
CGRect(x: cellWidth + spacing, y: 0, width: cellWidth, height: topHeight),
CGRect(x: 0, y: topHeight + spacing, width: cellWidth, height: bottomHeight),
CGRect(x: cellWidth + spacing, y: topHeight + spacing, width: cellWidth, height: bottomHeight)
]
default:
let topCellWidth = (width - spacing) / 2
let bottomCellWidth = (width - spacing * 2) / 3
var topHeight = min(topCellWidth * 0.85, 176)
var bottomHeight = min(bottomCellWidth * 0.85, 144)
let expectedHeight = topHeight + spacing + bottomHeight
if expectedHeight > 0 {
let scale = height / expectedHeight
topHeight *= scale
bottomHeight *= scale
}
return [
CGRect(x: 0, y: 0, width: topCellWidth, height: topHeight),
CGRect(x: topCellWidth + spacing, y: 0, width: topCellWidth, height: topHeight),
CGRect(x: 0, y: topHeight + spacing, width: bottomCellWidth, height: bottomHeight),
CGRect(x: bottomCellWidth + spacing, y: topHeight + spacing, width: bottomCellWidth, height: bottomHeight),
CGRect(x: (bottomCellWidth + spacing) * 2, y: topHeight + spacing, width: bottomCellWidth, height: bottomHeight)
]
}
}
private func setPhotoTileImage(_ image: UIImage?, at index: Int, animated: Bool) {
guard index >= 0, index < photoTileImageViews.count else { return }
let imageView = photoTileImageViews[index]
let update = {
imageView.image = image
}
if animated, image != nil, imageView.image != nil {
UIView.transition(
with: imageView,
duration: 0.18,
options: [.transitionCrossDissolve, .beginFromCurrentState, .allowUserInteraction],
animations: update
)
} else {
update()
}
imageView.isHidden = image == nil
}
private func layoutPhotoOverflowOverlay(frames: [CGRect]? = nil) {
let overflowCount = totalPhotoAttachmentCount - photoAttachments.count
guard overflowCount > 0,
!photoAttachments.isEmpty else {
photoOverflowOverlayView.isHidden = true
photoOverflowLabel.isHidden = true
photoOverflowLabel.text = nil
return
}
let lastVisibleIndex = photoAttachments.count - 1
guard lastVisibleIndex >= 0, lastVisibleIndex < photoTileImageViews.count else {
photoOverflowOverlayView.isHidden = true
photoOverflowLabel.isHidden = true
photoOverflowLabel.text = nil
return
}
let frame: CGRect
if let frames, lastVisibleIndex < frames.count {
frame = frames[lastVisibleIndex]
} else {
frame = photoTileImageViews[lastVisibleIndex].frame
}
guard frame.width > 0, frame.height > 0 else {
photoOverflowOverlayView.isHidden = true
photoOverflowLabel.isHidden = true
photoOverflowLabel.text = nil
return
}
photoOverflowOverlayView.frame = frame
photoOverflowOverlayView.isHidden = false
photoOverflowLabel.isHidden = false
photoOverflowLabel.text = "+\(overflowCount)"
photoOverflowLabel.font = UIFont.systemFont(
ofSize: max(18, min(frame.height * 0.34, 34)),
weight: .semibold
)
photoOverflowLabel.frame = photoOverflowOverlayView.bounds
}
private func tileIndex(for attachmentId: String) -> Int? {
photoAttachments.firstIndex(where: { $0.id == attachmentId })
}
private func startPhotoLoadTask(attachment: MessageAttachment) {
if photoLoadTasks[attachment.id] != nil { return }
let attachmentId = attachment.id
photoLoadTasks[attachmentId] = Task { [weak self] in
await ImageLoadLimiter.shared.acquire()
let loaded = await Task.detached(priority: .userInitiated) {
await AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
@@ -733,18 +965,23 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
await ImageLoadLimiter.shared.release()
guard !Task.isCancelled else { return }
await MainActor.run {
guard let self, self.photoAttachmentId == attachmentId, let loaded else { return }
self.photoView.image = loaded
self.photoView.isHidden = false
self.photoPlaceholderView.isHidden = true
self.photoActivityIndicator.stopAnimating()
self.isPhotoDownloading = false
guard let self else { return }
self.photoLoadTasks.removeValue(forKey: attachmentId)
guard let tileIndex = self.tileIndex(for: attachmentId),
tileIndex < self.photoTileImageViews.count,
let loaded else {
return
}
self.setPhotoTileImage(loaded, at: tileIndex, animated: true)
self.photoTilePlaceholderViews[tileIndex].isHidden = true
self.photoTileActivityIndicators[tileIndex].stopAnimating()
self.photoTileActivityIndicators[tileIndex].isHidden = true
}
}
}
private func downloadPhotoAttachment(attachment: MessageAttachment, message: ChatMessage) {
guard !isPhotoDownloading else { return }
if photoDownloadTasks[attachment.id] != nil { return }
let tag = Self.extractTag(from: attachment.preview)
guard !tag.isEmpty,
let storedPassword = message.attachmentPassword,
@@ -752,43 +989,78 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
return
}
isPhotoDownloading = true
photoActivityIndicator.startAnimating()
photoDownloadTask?.cancel()
let attachmentId = attachment.id
let preview = attachment.preview
downloadingAttachmentIds.insert(attachmentId)
if let tileIndex = tileIndex(for: attachmentId), tileIndex < photoTileActivityIndicators.count {
photoTileActivityIndicators[tileIndex].startAnimating()
photoTileActivityIndicators[tileIndex].isHidden = false
}
photoDownloadTask = Task { [weak self] in
photoDownloadTasks[attachmentId] = Task { [weak self] in
do {
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag)
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)
let image = Self.decryptAndParseImage(encryptedString: encryptedString, passwords: passwords)
await MainActor.run {
guard let self, self.photoAttachmentId == attachmentId else { return }
guard let self else { return }
self.photoDownloadTasks.removeValue(forKey: attachmentId)
self.downloadingAttachmentIds.remove(attachmentId)
guard let tileIndex = self.tileIndex(for: attachmentId),
tileIndex < self.photoTileImageViews.count else {
return
}
if let image {
AttachmentCache.shared.saveImage(image, forAttachmentId: attachmentId)
self.photoView.image = image
self.photoView.isHidden = false
self.photoPlaceholderView.isHidden = true
} else {
self.photoView.image = Self.blurHashImage(from: preview)
self.photoView.isHidden = false
self.photoPlaceholderView.isHidden = self.photoView.image != nil
self.setPhotoTileImage(image, at: tileIndex, animated: true)
self.photoTilePlaceholderViews[tileIndex].isHidden = true
}
self.photoActivityIndicator.stopAnimating()
self.isPhotoDownloading = false
self.photoTileActivityIndicators[tileIndex].stopAnimating()
self.photoTileActivityIndicators[tileIndex].isHidden = true
}
} catch {
await MainActor.run {
guard let self, self.photoAttachmentId == attachmentId else { return }
self.photoActivityIndicator.stopAnimating()
self.isPhotoDownloading = false
guard let self else { return }
self.photoDownloadTasks.removeValue(forKey: attachmentId)
self.downloadingAttachmentIds.remove(attachmentId)
guard let tileIndex = self.tileIndex(for: attachmentId),
tileIndex < self.photoTileActivityIndicators.count else {
return
}
self.photoTileActivityIndicators[tileIndex].stopAnimating()
self.photoTileActivityIndicators[tileIndex].isHidden = true
}
}
}
}
private func resetPhotoTiles() {
photoAttachments.removeAll()
totalPhotoAttachmentCount = 0
for task in photoLoadTasks.values {
task.cancel()
}
photoLoadTasks.removeAll()
for task in photoDownloadTasks.values {
task.cancel()
}
photoDownloadTasks.removeAll()
downloadingAttachmentIds.removeAll()
photoContainer.layer.mask = nil
photoOverflowOverlayView.isHidden = true
photoOverflowLabel.isHidden = true
photoOverflowLabel.text = nil
for index in 0..<photoTileImageViews.count {
photoTileImageViews[index].image = nil
photoTileImageViews[index].isHidden = true
photoTilePlaceholderViews[index].isHidden = true
photoTileActivityIndicators[index].stopAnimating()
photoTileActivityIndicators[index].isHidden = true
photoTileButtons[index].isHidden = true
}
}
private static func extractTag(from preview: String) -> String {
let parts = preview.components(separatedBy: "::")
return parts.first ?? preview
@@ -873,21 +1145,35 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private func animateCheckAppearanceIfNeeded(isSentVisible: Bool, isReadVisible: Bool) {
if isSentVisible && !wasSentCheckVisible {
let pop = CABasicAnimation(keyPath: "transform.scale")
pop.fromValue = NSNumber(value: Float(1.3))
pop.toValue = NSNumber(value: Float(1.0))
pop.duration = 0.1
pop.timingFunction = CAMediaTimingFunction(name: .easeOut)
checkSentView.layer.add(pop, forKey: "checkPop")
checkSentView.alpha = 0
checkSentView.transform = CGAffineTransform(translationX: 2, y: 0).scaledBy(x: 0.9, y: 0.9)
UIView.animate(
withDuration: 0.16,
delay: 0,
options: [.curveEaseOut, .beginFromCurrentState]
) {
self.checkSentView.alpha = 1
self.checkSentView.transform = .identity
}
} else if !isSentVisible {
checkSentView.alpha = 1
checkSentView.transform = .identity
}
if isReadVisible && !wasReadCheckVisible {
let pop = CABasicAnimation(keyPath: "transform.scale")
pop.fromValue = NSNumber(value: Float(1.3))
pop.toValue = NSNumber(value: Float(1.0))
pop.duration = 0.1
pop.timingFunction = CAMediaTimingFunction(name: .easeOut)
checkReadView.layer.add(pop, forKey: "checkPop")
checkReadView.alpha = 0
checkReadView.transform = CGAffineTransform(translationX: 2, y: 0).scaledBy(x: 0.9, y: 0.9)
UIView.animate(
withDuration: 0.16,
delay: 0.02,
options: [.curveEaseOut, .beginFromCurrentState]
) {
self.checkReadView.alpha = 1
self.checkReadView.transform = .identity
}
} else if !isReadVisible {
checkReadView.alpha = 1
checkReadView.transform = .identity
}
wasSentCheckVisible = isSentVisible
@@ -919,6 +1205,15 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
)
}
private func bringStatusOverlayToFront() {
bubbleView.bringSubviewToFront(statusBackgroundView)
bubbleView.bringSubviewToFront(timestampLabel)
bubbleView.bringSubviewToFront(checkSentView)
bubbleView.bringSubviewToFront(checkReadView)
bubbleView.bringSubviewToFront(clockFrameView)
bubbleView.bringSubviewToFront(clockMinView)
}
// MARK: - Reuse
override func prepareForReuse() {
@@ -931,8 +1226,12 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
timestampLabel.text = nil
checkSentView.image = nil
checkSentView.isHidden = true
checkSentView.alpha = 1
checkSentView.transform = .identity
checkReadView.image = nil
checkReadView.isHidden = true
checkReadView.alpha = 1
checkReadView.transform = .identity
clockFrameView.image = nil
clockFrameView.isHidden = true
clockMinView.image = nil
@@ -940,21 +1239,13 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
wasSentCheckVisible = false
wasReadCheckVisible = false
statusBackgroundView.isHidden = true
photoAttachmentId = nil
photoLoadTask?.cancel()
photoLoadTask = nil
photoDownloadTask?.cancel()
photoDownloadTask = nil
isPhotoDownloading = false
photoActivityIndicator.stopAnimating()
photoView.image = nil
resetPhotoTiles()
replyContainer.isHidden = true
fileContainer.isHidden = true
forwardLabel.isHidden = true
forwardAvatarView.isHidden = true
forwardNameLabel.isHidden = true
photoView.isHidden = true
photoPlaceholderView.isHidden = true
photoContainer.isHidden = true
bubbleView.transform = .identity
replyIconView.alpha = 0
deliveryFailedButton.isHidden = true