Чат: сузил bubble и улучшил пропорции media-сообщений
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user