Фикс: инсет маски шиммера скелетона + смешанные бабблы в групповом скелетоне

This commit is contained in:
2026-04-15 15:48:02 +05:00
parent 8561fecbfc
commit c3260889f4
4 changed files with 203 additions and 22 deletions

View File

@@ -1071,6 +1071,7 @@ private final class ChatDetailTitlePill: UIControl {
private let glassView = TelegramGlassUIView(frame: .zero)
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let typingDotsView = TypingDotsView()
private let route: ChatRoute
private weak var viewModel: ChatDetailViewModel?
@@ -1102,6 +1103,10 @@ private final class ChatDetailTitlePill: UIControl {
subtitleLabel.textAlignment = .center
subtitleLabel.lineBreakMode = .byTruncatingTail
addSubview(subtitleLabel)
typingDotsView.isUserInteractionEnabled = false
typingDotsView.isHidden = true
addSubview(typingDotsView)
}
private func observeChanges() {
@@ -1167,13 +1172,27 @@ private final class ChatDetailTitlePill: UIControl {
}
subtitleLabel.text = subtitle
subtitleLabel.textColor = subtitleColor
// Show/hide typing dots animation
let isTypingActive = (viewModel?.isTyping == true)
|| !(viewModel?.typingSenderNames ?? []).isEmpty
if isTypingActive {
typingDotsView.dotColor = subtitleColor
typingDotsView.isHidden = false
typingDotsView.startAnimating()
} else {
typingDotsView.isHidden = true
typingDotsView.stopAnimating()
}
setNeedsLayout()
}
/// Returns the natural content width (max of title/subtitle label widths).
func contentWidth() -> CGFloat {
let titleW = titleLabel.intrinsicContentSize.width
let subtitleW = subtitleLabel.intrinsicContentSize.width
return max(titleW, subtitleW)
let dotsExtra: CGFloat = typingDotsView.isHidden ? 0 : 24
return max(titleW, subtitleW + dotsExtra)
}
override func layoutSubviews() {
@@ -1195,7 +1214,24 @@ private final class ChatDetailTitlePill: UIControl {
let totalH = titleH + spacing + subtitleH
let topY = (bounds.height - totalH) / 2
titleLabel.frame = CGRect(x: hPad, y: topY, width: contentW, height: titleH)
subtitleLabel.frame = CGRect(x: hPad, y: topY + titleH + spacing, width: contentW, height: subtitleH)
let subtitleY = topY + titleH + spacing
if !typingDotsView.isHidden {
// Dots (24×14) + subtitle text, centered horizontally
let dotsW: CGFloat = 24
let dotsH: CGFloat = 14
let textW = subtitleLabel.intrinsicContentSize.width
let totalW = dotsW + textW
let startX = (bounds.width - totalW) / 2
typingDotsView.frame = CGRect(x: startX, y: subtitleY + (subtitleH - dotsH) / 2 + 1, width: dotsW, height: dotsH)
subtitleLabel.frame = CGRect(x: startX + dotsW, y: subtitleY, width: textW, height: subtitleH)
subtitleLabel.textAlignment = .left
} else {
typingDotsView.frame = .zero
subtitleLabel.frame = CGRect(x: hPad, y: subtitleY, width: contentW, height: subtitleH)
subtitleLabel.textAlignment = .center
}
} else {
titleLabel.frame = CGRect(x: hPad, y: 0, width: contentW, height: bounds.height)
}

View File

@@ -53,22 +53,22 @@ final class NativeSkeletonView: UIView {
BubbleSpec(widthFrac: 0.50, height: 44, outgoing: false),
]
// Groups: all incoming (Telegram parity)
// Groups: mix of incoming/outgoing (like a real group conversation)
private static let groupSpecs: [BubbleSpec] = [
BubbleSpec(widthFrac: 0.47, height: 44, outgoing: false),
BubbleSpec(widthFrac: 0.58, height: 44, outgoing: false),
BubbleSpec(widthFrac: 0.50, height: 44, outgoing: true),
BubbleSpec(widthFrac: 0.69, height: 60, outgoing: false),
BubbleSpec(widthFrac: 0.45, height: 44, outgoing: true),
BubbleSpec(widthFrac: 0.47, height: 44, outgoing: false),
BubbleSpec(widthFrac: 0.58, height: 44, outgoing: false),
BubbleSpec(widthFrac: 0.55, height: 44, outgoing: true),
BubbleSpec(widthFrac: 0.36, height: 60, outgoing: false),
BubbleSpec(widthFrac: 0.47, height: 44, outgoing: false),
BubbleSpec(widthFrac: 0.36, height: 44, outgoing: false),
BubbleSpec(widthFrac: 0.40, height: 44, outgoing: true),
BubbleSpec(widthFrac: 0.58, height: 44, outgoing: false),
BubbleSpec(widthFrac: 0.69, height: 60, outgoing: false),
BubbleSpec(widthFrac: 0.58, height: 44, outgoing: false),
BubbleSpec(widthFrac: 0.36, height: 44, outgoing: false),
BubbleSpec(widthFrac: 0.47, height: 44, outgoing: false),
BubbleSpec(widthFrac: 0.58, height: 44, outgoing: false),
BubbleSpec(widthFrac: 0.50, height: 44, outgoing: true),
]
private static let avatarSize: CGFloat = 38
@@ -99,6 +99,7 @@ final class NativeSkeletonView: UIView {
private let containerView = UIView()
private var bubbleImageViews: [UIImageView] = []
private var avatarViews: [UIView] = []
private var shimmerLayer: CALayer?
private var shimmerGradients: [CAGradientLayer] = []
private var isShimmerRunning = false
@@ -184,9 +185,12 @@ final class NativeSkeletonView: UIView {
bubbleImageViews.append(iv)
}
// Shimmer mask: use actual bubble path shape (not rect)
// Shimmer mask: inset path by 1pt so shimmer stays inside the raster bubble image
// (vector BezierPath vs 9-slice raster image have slightly different anti-aliased edges)
let maskInset: CGFloat = 1.0
let maskRect = CGRect(origin: .zero, size: bubbleFrame.size).insetBy(dx: maskInset, dy: maskInset)
let bubblePath = BubbleGeometryEngine.makeBezierPath(
in: CGRect(origin: .zero, size: bubbleFrame.size),
in: maskRect,
mergeType: .none,
outgoing: spec.outgoing,
metrics: metrics

View File

@@ -194,14 +194,15 @@ private extension ChatListSearchContent {
)
}
}
// Desktop parity: search subtitle shows @username, not online/offline.
// Telegram parity: subtitle shows online status, not @username
if !isSelf {
Text(user.username.isEmpty
? "@\(String(user.publicKey.prefix(10)))..."
: "@\(user.username)"
)
let dialog = DialogRepository.shared.dialogs[user.publicKey]
let isOnline = dialog?.isOnline ?? false
Text(isOnline ? "online" : "last seen recently")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.foregroundStyle(isOnline
? RosettaColors.primaryBlue
: RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
}

View File

@@ -88,6 +88,10 @@ final class ChatListRootViewController: UIViewController, UINavigationController
private var searchResultUsersByKey: [String: SearchUser] = [:]
private let searchTopSpacing: CGFloat = 10
private let searchBottomSpacing: CGFloat = 5
// Search overlay (shown when search is active)
private var searchOverlayView: UIView?
private var searchContentHosting: UIHostingController<AnyView>?
private let searchHeaderHeight: CGFloat = 44
private var searchChromeHeight: CGFloat {
searchTopSpacing + searchHeaderHeight + searchBottomSpacing
@@ -218,6 +222,9 @@ final class ChatListRootViewController: UIViewController, UINavigationController
editButtonControl.isUserInteractionEnabled = true
rightButtonsControl.isUserInteractionEnabled = true
toolbarTitleView.isUserInteractionEnabled = true
// Restore search bar position and tear down overlay
searchHeaderTopConstraint?.constant = headerBarHeight + searchTopSpacing
teardownSearchOverlayImmediately()
}
}
@@ -295,6 +302,7 @@ final class ChatListRootViewController: UIViewController, UINavigationController
self.currentSearchQuery = query
self.viewModel.setSearchQuery(query)
self.renderList()
self.updateSearchOverlayContent()
}
searchHeaderView.onActiveChanged = { [weak self] active in
@@ -302,6 +310,12 @@ final class ChatListRootViewController: UIViewController, UINavigationController
self.applySearchExpansion(1.0, animated: true)
self.updateNavigationBarBlur(progress: active ? 1.0 : (1.0 - self.lastSearchExpansion))
self.animateToolbarForSearch(active: active)
self.animateSearchBarPosition(active: active)
if active {
self.showSearchOverlay()
} else {
self.hideSearchOverlay()
}
self.onSearchActiveChanged?(active)
}
}
@@ -469,6 +483,119 @@ final class ChatListRootViewController: UIViewController, UINavigationController
toolbarTitleView.isUserInteractionEnabled = !active
}
// MARK: - Search Bar Position Animation
/// Telegram: search bar moves to just below status bar when active
private func animateSearchBarPosition(active: Bool) {
let targetTop: CGFloat = active
? searchTopSpacing // just below status bar
: headerBarHeight + searchTopSpacing // below custom toolbar
searchHeaderTopConstraint?.constant = targetTop
UIView.animate(
withDuration: 0.5,
delay: 0,
usingSpringWithDamping: 0.78,
initialSpringVelocity: 0,
options: [.beginFromCurrentState],
animations: { self.view.layoutIfNeeded() }
)
}
// MARK: - Search Overlay
private func showSearchOverlay() {
guard searchOverlayView == nil else { return }
let bg = UIView()
bg.backgroundColor = UIColor(RosettaColors.Adaptive.background)
bg.alpha = 0
bg.translatesAutoresizingMaskIntoConstraints = false
bg.layer.zPosition = 45
view.insertSubview(bg, belowSubview: searchHeaderView)
NSLayoutConstraint.activate([
bg.topAnchor.constraint(equalTo: searchHeaderView.bottomAnchor, constant: searchBottomSpacing),
bg.leadingAnchor.constraint(equalTo: view.leadingAnchor),
bg.trailingAnchor.constraint(equalTo: view.trailingAnchor),
bg.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
let content = makeSearchContentView()
let hosting = UIHostingController(rootView: AnyView(content))
hosting.view.backgroundColor = .clear
hosting.view.translatesAutoresizingMaskIntoConstraints = false
addChild(hosting)
bg.addSubview(hosting.view)
hosting.didMove(toParent: self)
NSLayoutConstraint.activate([
hosting.view.topAnchor.constraint(equalTo: bg.topAnchor),
hosting.view.leadingAnchor.constraint(equalTo: bg.leadingAnchor),
hosting.view.trailingAnchor.constraint(equalTo: bg.trailingAnchor),
hosting.view.bottomAnchor.constraint(equalTo: bg.bottomAnchor),
])
searchOverlayView = bg
searchContentHosting = hosting
UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseOut) {
bg.alpha = 1
}
}
private func hideSearchOverlay() {
guard let overlay = searchOverlayView else { return }
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseOut, animations: {
overlay.alpha = 0
}) { [weak self] _ in
guard let self else { return }
self.searchContentHosting?.willMove(toParent: nil)
self.searchContentHosting?.view.removeFromSuperview()
self.searchContentHosting?.removeFromParent()
self.searchContentHosting = nil
overlay.removeFromSuperview()
self.searchOverlayView = nil
}
}
private func updateSearchOverlayContent() {
guard let hosting = searchContentHosting else { return }
hosting.rootView = AnyView(makeSearchContentView())
}
private func makeSearchContentView() -> some View {
ChatListSearchContent(
searchText: currentSearchQuery,
viewModel: viewModel,
onSelectRecent: { [weak self] query in
guard let self else { return }
self.searchHeaderView.setQueryFromOutside(query)
},
onOpenDialog: { [weak self] route in
guard let self else { return }
self.searchHeaderView.endSearch(animated: false, clearText: true)
self.animateSearchBarPosition(active: false)
self.hideSearchOverlay()
self.animateToolbarForSearch(active: false)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
self?.openChat(route: route)
}
}
)
}
private func teardownSearchOverlayImmediately() {
searchContentHosting?.willMove(toParent: nil)
searchContentHosting?.view.removeFromSuperview()
searchContentHosting?.removeFromParent()
searchContentHosting = nil
searchOverlayView?.removeFromSuperview()
searchOverlayView = nil
}
private func applySearchExpansion(_ expansion: CGFloat, animated: Bool) {
let clamped = max(0.0, min(1.0, expansion))
if searchHeaderView.isSearchActive && clamped < 0.999 {
@@ -1019,6 +1146,11 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
setSearchActive(false, animated: animated, clearText: clearText)
}
/// Called from search overlay when user selects a recent search query
func setQueryFromOutside(_ text: String) {
setQueryText(text)
}
/// Telegram-style expansion animation:
/// - Corner radius scales with height (pill shape maintained)
/// - Text/icon alpha: invisible until 77% expanded, then ramps to 1.0
@@ -1084,10 +1216,13 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
activeStack.addArrangedSubview(inlineClearButton)
capsuleView.addSubview(activeStack)
// Telegram-style circular X button (replaces "Cancel" text)
cancelButton.translatesAutoresizingMaskIntoConstraints = false
cancelButton.setTitle("Cancel", for: .normal)
cancelButton.titleLabel?.font = .systemFont(ofSize: 17)
cancelButton.contentHorizontalAlignment = .right
let xConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
cancelButton.setImage(UIImage(systemName: "xmark", withConfiguration: xConfig), for: .normal)
cancelButton.setTitle(nil, for: .normal)
cancelButton.layer.cornerRadius = 18
cancelButton.clipsToBounds = true
cancelButton.addTarget(self, action: #selector(handleCancelTapped), for: .touchUpInside)
addSubview(cancelButton)
@@ -1100,10 +1235,11 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
capsuleView.leadingAnchor.constraint(equalTo: leadingAnchor),
capsuleView.topAnchor.constraint(equalTo: topAnchor),
capsuleView.bottomAnchor.constraint(equalTo: bottomAnchor),
capsuleView.trailingAnchor.constraint(equalTo: cancelButton.leadingAnchor),
capsuleView.trailingAnchor.constraint(equalTo: cancelButton.leadingAnchor, constant: -8),
cancelButton.trailingAnchor.constraint(equalTo: trailingAnchor),
cancelButton.centerYAnchor.constraint(equalTo: centerYAnchor),
cancelButton.heightAnchor.constraint(equalToConstant: 36),
cancelWidthConstraint,
placeholderStack.centerXAnchor.constraint(equalTo: capsuleView.centerXAnchor),
@@ -1136,7 +1272,11 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
// inputTextColor: dark #ffffff, light #000000
textField.textColor = isDark ? .white : .black
inlineClearButton.tintColor = placeholderColor
cancelButton.setTitleColor(UIColor(RosettaColors.primaryBlue), for: .normal)
// Telegram X button: dark circle background, white icon
cancelButton.backgroundColor = isDark
? UIColor(red: 0x2c/255.0, green: 0x2c/255.0, blue: 0x2e/255.0, alpha: 1.0)
: UIColor(red: 0xd1/255.0, green: 0xd1/255.0, blue: 0xd6/255.0, alpha: 1.0)
cancelButton.tintColor = isDark ? .white : .black
}
private func updateVisualState(animated: Bool) {
@@ -1144,7 +1284,7 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
self.placeholderStack.alpha = self.isSearchActive ? 0 : 1
self.activeStack.alpha = self.isSearchActive ? 1 : 0
self.cancelButton.alpha = self.isSearchActive ? 1 : 0
self.cancelWidthConstraint.constant = self.isSearchActive ? 64 : 0
self.cancelWidthConstraint.constant = self.isSearchActive ? 36 : 0
self.layoutIfNeeded()
}