Фикс: инсет маски шиммера скелетона + смешанные бабблы в групповом скелетоне
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user