Чат: сузил bubble и улучшил пропорции media-сообщений

This commit is contained in:
2026-03-28 08:38:03 +05:00
parent d706ef6d62
commit 1978db0f38
3 changed files with 125 additions and 15 deletions

View File

@@ -211,9 +211,21 @@ extension MessageCellLayout {
// Tiny floor just to prevent zero-width collapse.
// Telegram does NOT force a large minW short messages get tight bubbles.
let minW: CGFloat = 40
let mediaWidthFraction: CGFloat
let mediaAbsoluteCap: CGFloat
if config.imageCount == 1 {
mediaWidthFraction = 0.64
mediaAbsoluteCap = 288
} else if config.imageCount == 2 {
mediaWidthFraction = 0.67
mediaAbsoluteCap = 300
} else {
mediaWidthFraction = 0.7
mediaAbsoluteCap = 312
}
let mediaBubbleMaxWidth = min(
effectiveMaxBubbleWidth,
max(220, UIScreen.main.bounds.width * 0.78)
min(mediaAbsoluteCap, max(200, UIScreen.main.bounds.width * mediaWidthFraction))
)
var bubbleW: CGFloat
@@ -390,14 +402,14 @@ extension MessageCellLayout {
/// Photo collage height same formulas as C++ MessageLayout & PhotoCollageView.swift.
private static func collageHeight(count: Int, width: CGFloat) -> CGFloat {
guard count > 0 else { return 0 }
if count == 1 { return min(width * 0.75, 320) }
if count == 1 { return max(180, min(width * 0.93, 340)) }
if count == 2 {
let cellW = (width - 2) / 2
return min(cellW * 1.2, 320)
return min(cellW * 1.28, 330)
}
if count == 3 {
let leftW = width * 0.66
return min(leftW * 1.1, 320)
return min(leftW * 1.16, 330)
}
if count == 4 {
let cellW = (width - 2) / 2

View File

@@ -129,9 +129,9 @@ struct ChatDetailView: View {
private var maxBubbleWidth: CGFloat {
let w = UIScreen.main.bounds.width
if w <= 500 {
return max(240, min(w * 0.78, w - 86))
return max(224, min(w * 0.72, w - 104))
}
return min(w * 0.72, 520)
return min(w * 0.66, 460)
}
/// Visual chat content: messages list + gradient overlays + background.

View File

@@ -163,7 +163,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private var photoTileImageViews: [UIImageView] = []
private var photoTilePlaceholderViews: [UIView] = []
private var photoTileActivityIndicators: [UIActivityIndicatorView] = []
private var photoTileErrorViews: [UIImageView] = []
private var photoTileButtons: [UIButton] = []
private let photoUploadingOverlayView = UIView()
private let photoUploadingIndicator = UIActivityIndicatorView(style: .medium)
private let photoOverflowOverlayView = UIView()
private let photoOverflowLabel = UILabel()
@@ -195,6 +198,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private var photoLoadTasks: [String: Task<Void, Never>] = [:]
private var photoDownloadTasks: [String: Task<Void, Never>] = [:]
private var downloadingAttachmentIds: Set<String> = []
private var failedAttachmentIds: Set<String> = []
// MARK: - Init
@@ -230,8 +234,9 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
bubbleView.addSubview(textLabel)
// Timestamp
statusBackgroundView.backgroundColor = UIColor.black.withAlphaComponent(0.32)
statusBackgroundView.layer.cornerRadius = 6
statusBackgroundView.backgroundColor = UIColor.black.withAlphaComponent(0.3)
statusBackgroundView.layer.cornerRadius = 7
statusBackgroundView.layer.cornerCurve = .continuous
statusBackgroundView.isHidden = true
bubbleView.addSubview(statusBackgroundView)
@@ -281,6 +286,11 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
indicator.isHidden = true
photoContainer.addSubview(indicator)
let errorView = UIImageView(image: Self.errorIcon)
errorView.contentMode = .center
errorView.isHidden = true
photoContainer.addSubview(errorView)
let button = UIButton(type: .custom)
button.tag = index
button.isHidden = true
@@ -290,9 +300,20 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
photoTileImageViews.append(imageView)
photoTilePlaceholderViews.append(placeholderView)
photoTileActivityIndicators.append(indicator)
photoTileErrorViews.append(errorView)
photoTileButtons.append(button)
}
photoUploadingOverlayView.backgroundColor = UIColor.black.withAlphaComponent(0.22)
photoUploadingOverlayView.isHidden = true
photoUploadingOverlayView.isUserInteractionEnabled = false
photoContainer.addSubview(photoUploadingOverlayView)
photoUploadingIndicator.color = .white
photoUploadingIndicator.hidesWhenStopped = true
photoUploadingIndicator.isHidden = true
photoContainer.addSubview(photoUploadingIndicator)
photoOverflowOverlayView.backgroundColor = UIColor.black.withAlphaComponent(0.45)
photoOverflowOverlayView.layer.cornerCurve = .continuous
photoOverflowOverlayView.layer.cornerRadius = 0
@@ -516,11 +537,23 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
bubbleLayer.shadowPath = bubbleLayer.path
bubbleOutlineLayer.frame = bubbleView.bounds
bubbleOutlineLayer.path = bubbleLayer.path
if layout.hasTail {
let hasPhotoContent = layout.hasPhoto
if hasPhotoContent {
bubbleLayer.shadowOpacity = 0.04
bubbleLayer.shadowRadius = 0.4
bubbleLayer.shadowOffset = CGSize(width: 0, height: 0.2)
bubbleOutlineLayer.strokeColor = UIColor.clear.cgColor
} else if layout.hasTail {
// Tail path is appended as a second subpath; stroking it produces
// a visible seam at the junction. Keep fill-only for tailed bubbles.
bubbleLayer.shadowOpacity = 0.12
bubbleLayer.shadowRadius = 0.6
bubbleLayer.shadowOffset = CGSize(width: 0, height: 0.4)
bubbleOutlineLayer.strokeColor = UIColor.clear.cgColor
} else {
bubbleLayer.shadowOpacity = 0.12
bubbleLayer.shadowRadius = 0.6
bubbleLayer.shadowOffset = CGSize(width: 0, height: 0.4)
bubbleOutlineLayer.strokeColor = UIColor.black.withAlphaComponent(
layout.isOutgoing ? 0.16 : 0.22
).cgColor
@@ -732,6 +765,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
task.cancel()
photoDownloadTasks.removeValue(forKey: attachmentId)
downloadingAttachmentIds.remove(attachmentId)
failedAttachmentIds.remove(attachmentId)
}
for index in 0..<photoTileImageViews.count {
@@ -739,6 +773,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
let imageView = photoTileImageViews[index]
let placeholderView = photoTilePlaceholderViews[index]
let indicator = photoTileActivityIndicators[index]
let errorView = photoTileErrorViews[index]
let button = photoTileButtons[index]
button.isHidden = !isActiveTile
@@ -749,29 +784,42 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
placeholderView.isHidden = true
indicator.stopAnimating()
indicator.isHidden = true
errorView.isHidden = true
continue
}
let attachment = photoAttachments[index]
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
failedAttachmentIds.remove(attachment.id)
setPhotoTileImage(cached, at: index, animated: false)
placeholderView.isHidden = true
indicator.stopAnimating()
indicator.isHidden = true
errorView.isHidden = true
} else {
setPhotoTileImage(Self.blurHashImage(from: attachment.preview), at: index, animated: false)
placeholderView.isHidden = imageView.image != nil
if downloadingAttachmentIds.contains(attachment.id) {
let hasFailed = failedAttachmentIds.contains(attachment.id)
if hasFailed {
indicator.stopAnimating()
indicator.isHidden = true
errorView.isHidden = false
} else if downloadingAttachmentIds.contains(attachment.id) {
indicator.startAnimating()
indicator.isHidden = false
errorView.isHidden = true
} else {
indicator.stopAnimating()
indicator.isHidden = true
errorView.isHidden = true
}
startPhotoLoadTask(attachment: attachment)
}
}
layoutPhotoOverflowOverlay()
updatePhotoUploadingOverlay(
isVisible: layout.isOutgoing && message.deliveryStatus == .waiting
)
}
private func layoutPhotoTiles() {
@@ -783,7 +831,19 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
photoTilePlaceholderViews[index].frame = frame
photoTileButtons[index].frame = frame
photoTileActivityIndicators[index].center = CGPoint(x: frame.midX, y: frame.midY)
photoTileErrorViews[index].frame = CGRect(
x: frame.midX - 10, y: frame.midY - 10,
width: 20, height: 20
)
}
photoUploadingOverlayView.frame = photoContainer.bounds
photoUploadingIndicator.center = CGPoint(
x: photoContainer.bounds.midX,
y: photoContainer.bounds.midY
)
photoContainer.bringSubviewToFront(photoUploadingOverlayView)
photoContainer.bringSubviewToFront(photoUploadingIndicator)
photoContainer.bringSubviewToFront(photoOverflowOverlayView)
layoutPhotoOverflowOverlay(frames: frames)
}
@@ -795,14 +855,21 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
let inset: CGFloat = 2
let r: CGFloat = max(16 - inset, 0)
let s: CGFloat = max(8 - inset, 0)
let s: CGFloat = max(5 - inset, 0)
let tailJoin: CGFloat = max(10 - 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 .single:
return layout.isOutgoing
? (r, r, r, tailJoin)
: (r, r, tailJoin, 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)
case .bottom:
return layout.isOutgoing
? (r, s, r, tailJoin)
: (s, r, tailJoin, r)
}
}()
@@ -950,6 +1017,17 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
photoOverflowLabel.frame = photoOverflowOverlayView.bounds
}
private func updatePhotoUploadingOverlay(isVisible: Bool) {
photoUploadingOverlayView.isHidden = !isVisible
if isVisible {
photoUploadingIndicator.isHidden = false
photoUploadingIndicator.startAnimating()
} else {
photoUploadingIndicator.stopAnimating()
photoUploadingIndicator.isHidden = true
}
}
private func tileIndex(for attachmentId: String) -> Int? {
photoAttachments.firstIndex(where: { $0.id == attachmentId })
}
@@ -972,10 +1050,12 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
let loaded else {
return
}
self.failedAttachmentIds.remove(attachmentId)
self.setPhotoTileImage(loaded, at: tileIndex, animated: true)
self.photoTilePlaceholderViews[tileIndex].isHidden = true
self.photoTileActivityIndicators[tileIndex].stopAnimating()
self.photoTileActivityIndicators[tileIndex].isHidden = true
self.photoTileErrorViews[tileIndex].isHidden = true
}
}
}
@@ -986,14 +1066,22 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
guard !tag.isEmpty,
let storedPassword = message.attachmentPassword,
!storedPassword.isEmpty else {
failedAttachmentIds.insert(attachment.id)
if let tileIndex = tileIndex(for: attachment.id), tileIndex < photoTileErrorViews.count {
photoTileActivityIndicators[tileIndex].stopAnimating()
photoTileActivityIndicators[tileIndex].isHidden = true
photoTileErrorViews[tileIndex].isHidden = false
}
return
}
let attachmentId = attachment.id
failedAttachmentIds.remove(attachmentId)
downloadingAttachmentIds.insert(attachmentId)
if let tileIndex = tileIndex(for: attachmentId), tileIndex < photoTileActivityIndicators.count {
photoTileActivityIndicators[tileIndex].startAnimating()
photoTileActivityIndicators[tileIndex].isHidden = false
photoTileErrorViews[tileIndex].isHidden = true
}
photoDownloadTasks[attachmentId] = Task { [weak self] in
@@ -1011,9 +1099,14 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
return
}
if let image {
self.failedAttachmentIds.remove(attachmentId)
AttachmentCache.shared.saveImage(image, forAttachmentId: attachmentId)
self.setPhotoTileImage(image, at: tileIndex, animated: true)
self.photoTilePlaceholderViews[tileIndex].isHidden = true
self.photoTileErrorViews[tileIndex].isHidden = true
} else {
self.failedAttachmentIds.insert(attachmentId)
self.photoTileErrorViews[tileIndex].isHidden = false
}
self.photoTileActivityIndicators[tileIndex].stopAnimating()
self.photoTileActivityIndicators[tileIndex].isHidden = true
@@ -1023,12 +1116,14 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
guard let self else { return }
self.photoDownloadTasks.removeValue(forKey: attachmentId)
self.downloadingAttachmentIds.remove(attachmentId)
self.failedAttachmentIds.insert(attachmentId)
guard let tileIndex = self.tileIndex(for: attachmentId),
tileIndex < self.photoTileActivityIndicators.count else {
return
}
self.photoTileActivityIndicators[tileIndex].stopAnimating()
self.photoTileActivityIndicators[tileIndex].isHidden = true
self.photoTileErrorViews[tileIndex].isHidden = false
}
}
}
@@ -1046,7 +1141,9 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
}
photoDownloadTasks.removeAll()
downloadingAttachmentIds.removeAll()
failedAttachmentIds.removeAll()
photoContainer.layer.mask = nil
updatePhotoUploadingOverlay(isVisible: false)
photoOverflowOverlayView.isHidden = true
photoOverflowLabel.isHidden = true
photoOverflowLabel.text = nil
@@ -1057,6 +1154,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
photoTilePlaceholderViews[index].isHidden = true
photoTileActivityIndicators[index].stopAnimating()
photoTileActivityIndicators[index].isHidden = true
photoTileErrorViews[index].isHidden = true
photoTileButtons[index].isHidden = true
}
}
@@ -1271,7 +1369,7 @@ extension NativeMessageCell: UIGestureRecognizerDelegate {
final class BubblePathCache {
static let shared = BubblePathCache()
private let pathVersion = 7
private let pathVersion = 8
private var cache: [String: CGPath] = [:]
func path(
@@ -1296,7 +1394,7 @@ final class BubblePathCache {
private func makeBubblePath(
in rect: CGRect, position: BubblePosition, isOutgoing: Bool, hasTail: Bool
) -> CGPath {
let r: CGFloat = 16, s: CGFloat = 8, tailW: CGFloat = 6
let r: CGFloat = 16, s: CGFloat = 5, tailW: CGFloat = 6
// Body rect
let bodyRect: CGRect