Голосовые сообщения — фиксы lock view, cancel анимация, recording panel UI

This commit is contained in:
2026-04-12 23:30:00 +05:00
parent 30f333ef90
commit 08a1da64a8
13 changed files with 751 additions and 155 deletions

View File

@@ -53,6 +53,10 @@ final class ChatListCell: UICollectionViewCell {
// Message row
let messageLabel = UILabel()
// Typing indicator
let typingDotsView = TypingDotsView()
let typingLabel = UILabel()
// Trailing column
let dateLabel = UILabel()
let statusImageView = UIImageView()
@@ -151,6 +155,14 @@ final class ChatListCell: UICollectionViewCell {
messageLabel.lineBreakMode = .byTruncatingTail
contentView.addSubview(messageLabel)
// Typing indicator (hidden by default)
typingDotsView.isHidden = true
contentView.addSubview(typingDotsView)
typingLabel.font = .systemFont(ofSize: 15, weight: .regular)
typingLabel.isHidden = true
contentView.addSubview(typingLabel)
// Date
dateLabel.font = .systemFont(ofSize: 14, weight: .regular)
dateLabel.textAlignment = .right
@@ -348,6 +360,17 @@ final class ChatListCell: UICollectionViewCell {
)
}
// Typing indicator
// Y=30 so visual center matches 2-line message visual center (~40pt).
// Dots (16pt) centered within text height (20pt) Y+2.
if !typingDotsView.isHidden {
let dotsW: CGFloat = 24
let dotsH: CGFloat = 16
let typingY: CGFloat = 30
typingLabel.frame = CGRect(x: textLeft + dotsW - 2, y: typingY, width: max(0, messageMaxW - dotsW + 2), height: 20)
typingDotsView.frame = CGRect(x: textLeft, y: typingY + 2, width: dotsW, height: dotsH)
}
// Separator
let separatorHeight = 1.0 / scale
separatorView.frame = CGRect(
@@ -363,7 +386,7 @@ final class ChatListCell: UICollectionViewCell {
/// Message text cache (shared across cells, avoids regex per configure).
private static var messageTextCache: [String: String] = [:]
func configure(with dialog: Dialog, isSyncing: Bool) {
func configure(with dialog: Dialog, isSyncing: Bool, typingUsers: Set<String>? = nil) {
let isDark = traitCollection.userInterfaceStyle == .dark
isPinned = dialog.isPinned
@@ -408,8 +431,14 @@ final class ChatListCell: UICollectionViewCell {
mutedIconView.isHidden = !dialog.isMuted
mutedIconView.tintColor = secondaryColor
// Message text (typing is NOT shown in chat list only inside chat detail)
configureMessageText(dialog: dialog, secondaryColor: secondaryColor, titleColor: titleColor)
// Message text or typing indicator
let activeTyping = typingUsers.flatMap { $0.isEmpty ? nil : $0 }
if let typers = activeTyping {
configureTypingIndicator(dialog: dialog, typingUsers: typers, color: secondaryColor)
} else {
hideTypingIndicator()
configureMessageText(dialog: dialog, secondaryColor: secondaryColor, titleColor: titleColor)
}
// Date
dateLabel.text = formatTime(dialog.lastMessageTimestamp)
@@ -667,6 +696,49 @@ final class ChatListCell: UICollectionViewCell {
messageLabel.textColor = secondaryColor
}
// MARK: - Typing Indicator
private func configureTypingIndicator(dialog: Dialog, typingUsers: Set<String>, color: UIColor) {
// Hide normal message content
messageLabel.isHidden = true
authorLabel.isHidden = true
// Show typing
typingDotsView.isHidden = false
typingDotsView.dotColor = color
typingDotsView.startAnimating()
typingLabel.isHidden = false
typingLabel.textColor = color
if dialog.isGroup {
let names = typingUsers.prefix(2).map { key -> String in
if let d = DialogRepository.shared.dialogs[key], !d.opponentTitle.isEmpty {
return d.opponentTitle
}
return String(key.prefix(8))
}
if typingUsers.count == 1 {
typingLabel.text = "\(names[0]) typing"
} else if typingUsers.count == 2 {
typingLabel.text = "\(names[0]), \(names[1]) typing"
} else {
typingLabel.text = "\(names[0]) and \(typingUsers.count - 1) others typing"
}
} else {
typingLabel.text = "typing"
}
}
private func hideTypingIndicator() {
typingDotsView.stopAnimating()
typingDotsView.isHidden = true
typingLabel.isHidden = true
messageLabel.isHidden = false
}
// MARK: - Message Text Resolve
private func resolveMessageText(dialog: Dialog) -> String {
let raw = dialog.lastMessage.trimmingCharacters(in: .whitespacesAndNewlines)
if raw.isEmpty { return "No messages yet" }
@@ -778,6 +850,10 @@ final class ChatListCell: UICollectionViewCell {
messageLabel.attributedText = nil
messageLabel.numberOfLines = 2
authorLabel.isHidden = true
// Typing indicator
typingDotsView.stopAnimating()
typingDotsView.isHidden = true
typingLabel.isHidden = true
// Badge animation state
wasBadgeVisible = false
wasMentionBadgeVisible = false

View File

@@ -129,11 +129,13 @@ final class ChatListCollectionController: UIViewController {
cellRegistration = UICollectionView.CellRegistration<ChatListCell, Dialog> {
[weak self] cell, indexPath, dialog in
guard let self else { return }
cell.configure(with: dialog, isSyncing: self.isSyncing)
// Hide separator for first cell in first dialog section
let isFirstDialogSection = (self.sectionForIndexPath(indexPath) == .pinned && self.requestsCount == 0)
|| (self.sectionForIndexPath(indexPath) == .unpinned && self.pinnedDialogs.isEmpty && self.requestsCount == 0)
cell.setSeparatorHidden(indexPath.item == 0 && isFirstDialogSection)
let typingUsers = self.typingDialogs[dialog.opponentKey]
cell.configure(with: dialog, isSyncing: self.isSyncing, typingUsers: typingUsers)
// Hide separator for last cell in pinned/unpinned section
let section = self.sectionForIndexPath(indexPath)
let isLastInPinned = section == .pinned && indexPath.item == self.pinnedDialogs.count - 1
let isLastInUnpinned = section == .unpinned && indexPath.item == self.unpinnedDialogs.count - 1
cell.setSeparatorHidden(isLastInPinned || isLastInUnpinned)
}
requestsCellRegistration = UICollectionView.CellRegistration<ChatListRequestsCell, Int> {
@@ -235,7 +237,8 @@ final class ChatListCollectionController: UIViewController {
guard let itemId = dataSource.itemIdentifier(for: indexPath) else { continue }
if let chatCell = cell as? ChatListCell, let dialog = dialogMap[itemId] {
chatCell.configure(with: dialog, isSyncing: isSyncing)
let typingUsers = typingDialogs[dialog.opponentKey]
chatCell.configure(with: dialog, isSyncing: isSyncing, typingUsers: typingUsers)
} else if let reqCell = cell as? ChatListRequestsCell {
reqCell.configure(count: requestsCount)
}

View File

@@ -0,0 +1,115 @@
import UIKit
import SwiftUI
/// Animated typing dots indicator matching Telegram iOS exactly.
/// Uses CADisplayLink for smooth animation of 3 pulsing dots.
///
/// Reference: Telegram-iOS `ChatTypingActivityContentNode.swift`
/// - minDiameter: 3.0, maxDiameter: 4.5
/// - duration: 0.7s, timeOffsets: [0.4, 0.2, 0.0]
/// - alpha range: 0.751.0
/// - total size: 24×16
final class TypingDotsView: UIView {
var dotColor: UIColor = UIColor(red: 0x8E/255, green: 0x8E/255, blue: 0x93/255, alpha: 1) {
didSet { setNeedsDisplay() }
}
private var displayLink: CADisplayLink?
private var startTime: CFTimeInterval = 0
// Telegram-exact constants
private let animDuration: CFTimeInterval = 0.7
private let minD: CGFloat = 3.0
private let maxD: CGFloat = 4.5
private let dotDistance: CGFloat = 5.5 // 11.0 / 2.0
private let leftPad: CGFloat = 6.0
private let minAlpha: CGFloat = 0.75
private let deltaAlpha: CGFloat = 0.25 // 1.0 - 0.75
private let timeOffsets: [CGFloat] = [0.4, 0.2, 0.0]
override init(frame: CGRect) {
super.init(frame: frame)
isOpaque = false
backgroundColor = .clear
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
displayLink?.invalidate()
}
func startAnimating() {
guard displayLink == nil else { return }
startTime = CACurrentMediaTime()
let link = CADisplayLink(target: self, selector: #selector(tick))
link.add(to: .main, forMode: .common)
displayLink = link
}
func stopAnimating() {
displayLink?.invalidate()
displayLink = nil
}
@objc private func tick() {
setNeedsDisplay()
}
override func draw(_ rect: CGRect) {
guard let ctx = UIGraphicsGetCurrentContext() else { return }
let progress = CGFloat(fmod(CACurrentMediaTime() - startTime, animDuration) / animDuration)
let centerY = rect.height / 2
for (i, offset) in timeOffsets.enumerated() {
var r = radiusFunc(progress, timeOffset: offset)
r = (max(minD, r) - minD) / (maxD - minD) * 1.5
let alpha = (r * deltaAlpha + minAlpha)
ctx.setFillColor(dotColor.withAlphaComponent(alpha).cgColor)
let x = leftPad + CGFloat(i) * dotDistance
let size = minD + r
ctx.fillEllipse(in: CGRect(
x: x - size / 2,
y: centerY - size / 2,
width: size,
height: size
))
}
}
// Telegram-exact radius function (ChatTypingActivityContentNode.swift)
private func radiusFunc(_ value: CGFloat, timeOffset: CGFloat) -> CGFloat {
var v = value + timeOffset
if v > 1.0 { v -= floor(v) }
if v < 0.4 {
return (1.0 - v / 0.4) * minD + (v / 0.4) * maxD
} else if v < 0.8 {
return (1.0 - (v - 0.4) / 0.4) * maxD + ((v - 0.4) / 0.4) * minD
}
return minD
}
}
// MARK: - SwiftUI Bridge
/// SwiftUI wrapper for TypingDotsView used in ChatDetail toolbar capsule.
struct TypingDotsRepresentable: UIViewRepresentable {
let color: UIColor
func makeUIView(context: Context) -> TypingDotsView {
let view = TypingDotsView()
view.dotColor = color
view.startAnimating()
return view
}
func updateUIView(_ view: TypingDotsView, context: Context) {
view.dotColor = color
}
}