Кастомный header чат-листа в стиле Telegram — glass кнопки, анимация search bar, snap при скролле

This commit is contained in:
2026-04-14 19:10:10 +05:00
parent 400538bf2a
commit e5c0a270df
2 changed files with 102 additions and 119 deletions

View File

@@ -41,6 +41,8 @@ final class ChatListCollectionController: UIViewController {
private var lastReportedExpansion: CGFloat = 1.0
private var lastReportedPinnedHeaderFraction: CGFloat = -1.0
private let searchCollapseDistance: CGFloat = 54
/// Extra top offset for custom header bar (nav bar is hidden)
var customHeaderBarHeight: CGFloat = 44
private var searchHeaderExpansion: CGFloat = 1.0
private var hasInitializedTopOffset = false
private var isPinnedFractionReportScheduled = false
@@ -121,7 +123,7 @@ final class ChatListCollectionController: UIViewController {
private func applyInsets() {
guard collectionView != nil else { return }
let oldTopInset = collectionView.contentInset.top
let topInset = view.safeAreaInsets.top + (searchCollapseDistance * searchHeaderExpansion)
let topInset = view.safeAreaInsets.top + customHeaderBarHeight + (searchCollapseDistance * searchHeaderExpansion)
let bottomInset = chatListBottomInset
collectionView.contentInset.top = topInset
collectionView.contentInset.bottom = bottomInset
@@ -465,7 +467,7 @@ extension ChatListCollectionController: UICollectionViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// Only react to user-driven scroll, not programmatic/layout changes
guard scrollView.isDragging || scrollView.isDecelerating else { return }
let offset = scrollView.contentOffset.y + view.safeAreaInsets.top + searchCollapseDistance
let offset = scrollView.contentOffset.y + view.safeAreaInsets.top + customHeaderBarHeight + searchCollapseDistance
let expansion = max(0.0, min(1.0, 1.0 - offset / searchCollapseDistance))
if abs(expansion - lastReportedExpansion) > 0.005 {
lastReportedExpansion = expansion
@@ -482,11 +484,11 @@ extension ChatListCollectionController: UICollectionViewDelegate {
// Telegram snap-to-edge: if search bar is partially visible, snap to
// fully visible (>50%) or fully hidden (<50%).
guard lastReportedExpansion > 0.0 && lastReportedExpansion < 1.0 else { return }
let safeTop = view.safeAreaInsets.top
let headerTop = view.safeAreaInsets.top + customHeaderBarHeight
if lastReportedExpansion < 0.5 {
targetContentOffset.pointee.y = -safeTop
targetContentOffset.pointee.y = -headerTop
} else {
targetContentOffset.pointee.y = -(safeTop + searchCollapseDistance)
targetContentOffset.pointee.y = -(headerTop + searchCollapseDistance)
}
}

View File

@@ -88,7 +88,7 @@ final class ChatListRootViewController: UIViewController, UINavigationController
private var typingDialogs: [String: Set<String>] = [:]
private var currentSearchQuery = ""
private var searchResultUsersByKey: [String: SearchUser] = [:]
private let searchTopSpacing: CGFloat = 5
private let searchTopSpacing: CGFloat = 10
private let searchBottomSpacing: CGFloat = 5
private let searchHeaderHeight: CGFloat = 44
private var searchChromeHeight: CGFloat {
@@ -275,8 +275,8 @@ final class ChatListRootViewController: UIViewController, UINavigationController
NSLayoutConstraint.activate([
top,
searchHeaderView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
searchHeaderView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
searchHeaderView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16),
searchHeaderView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),
height,
])
@@ -445,20 +445,20 @@ final class ChatListRootViewController: UIViewController, UINavigationController
guard abs(clamped - lastSearchExpansion) > 0.003 else { return }
lastSearchExpansion = clamped
// Telegram non-linear: search bar stays fully visible for first 27% of scroll
let visibleProgress = max(0.0, min(1.0, (clamped - 0.267) / 0.733))
// Structural: use raw clamped
searchHeaderTopConstraint?.constant = searchTopSpacing * clamped
// Structural: top stays fixed, height collapses (Telegram: 60*progress, we use 44)
searchHeaderTopConstraint?.constant = headerBarHeight + searchTopSpacing
searchHeaderHeightConstraint?.constant = searchHeaderHeight * clamped
listController.setSearchHeaderExpansion(clamped)
// Visual: use non-linear visibleProgress
searchHeaderView.alpha = visibleProgress
searchHeaderView.isUserInteractionEnabled = visibleProgress > 0.2
let yShift = -8.0 * (1.0 - clamped)
searchHeaderView.transform = CGAffineTransform(translationX: 0, y: yShift)
.scaledBy(x: 1.0, y: 0.92 + 0.08 * visibleProgress)
// Telegram animation: NO scale transform, NO whole-view alpha.
// Height reduction + clipping handles the collapse.
// Only content (text/icon) fades separately.
searchHeaderView.transform = .identity
searchHeaderView.alpha = 1.0
searchHeaderView.isUserInteractionEnabled = clamped > 0.2
// Update internal content alpha + corner radius (Telegram behavior)
searchHeaderView.updateExpansionProgress(clamped)
updateNavigationBlurHeight()
updateNavigationBarBlur(progress: 1.0 - clamped)
@@ -838,12 +838,30 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
setSearchActive(false, animated: animated, clearText: clearText)
}
/// Telegram-style expansion animation:
/// - Corner radius scales with height (pill shape maintained)
/// - Text/icon alpha: invisible until 77% expanded, then ramps to 1.0
/// - Background: always visible (controlled by height clipping)
func updateExpansionProgress(_ progress: CGFloat) {
let currentHeight = searchBarHeight * progress
// Dynamic corner radius Telegram: height * 0.5
capsuleView.layer.cornerRadius = max(0, currentHeight * 0.5)
// Telegram inner content alpha: 0 until 77%, then ramps to 1
let innerAlpha = max(0.0, min(1.0, (progress - 0.77) / 0.23))
placeholderStack.alpha = isSearchActive ? 0 : innerAlpha
placeholderIcon.alpha = innerAlpha
placeholderLabel.alpha = innerAlpha
}
private let searchBarHeight: CGFloat = 44
private func setupUI() {
translatesAutoresizingMaskIntoConstraints = false
capsuleView.translatesAutoresizingMaskIntoConstraints = false
capsuleView.layer.cornerRadius = 22
capsuleView.layer.borderWidth = 0.5
capsuleView.layer.borderWidth = 0
capsuleView.clipsToBounds = true
addSubview(capsuleView)
@@ -901,7 +919,7 @@ 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, constant: -8),
capsuleView.trailingAnchor.constraint(equalTo: cancelButton.leadingAnchor),
cancelButton.trailingAnchor.constraint(equalTo: trailingAnchor),
cancelButton.centerYAnchor.constraint(equalTo: centerYAnchor),
@@ -921,13 +939,22 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
}
private func applyColors() {
capsuleView.backgroundColor = UIColor(RosettaColors.Adaptive.searchBarFill)
capsuleView.layer.borderColor = UIColor(RosettaColors.Adaptive.searchBarBorder).cgColor
placeholderLabel.textColor = .gray
placeholderIcon.tintColor = .gray
activeIcon.tintColor = .gray
textField.textColor = UIColor(RosettaColors.Adaptive.text)
inlineClearButton.tintColor = .gray
// Telegram exact colors from DefaultDarkPresentationTheme / DefaultDayPresentationTheme
let isDark = traitCollection.userInterfaceStyle == .dark
// regularSearchBarColor: dark #272728, light #e9e9e9
capsuleView.backgroundColor = isDark
? UIColor(red: 0x27/255.0, green: 0x27/255.0, blue: 0x28/255.0, alpha: 1.0)
: UIColor(red: 0xe9/255.0, green: 0xe9/255.0, blue: 0xe9/255.0, alpha: 1.0)
// inputPlaceholderTextColor: dark #8f8f8f, light #8e8e93
let placeholderColor = isDark
? UIColor(red: 0x8f/255.0, green: 0x8f/255.0, blue: 0x8f/255.0, alpha: 1.0)
: UIColor(red: 0x8e/255.0, green: 0x8e/255.0, blue: 0x93/255.0, alpha: 1.0)
placeholderLabel.textColor = placeholderColor
placeholderIcon.tintColor = placeholderColor
activeIcon.tintColor = placeholderColor
// inputTextColor: dark #ffffff, light #000000
textField.textColor = isDark ? .white : .black
inlineClearButton.tintColor = placeholderColor
cancelButton.setTitleColor(UIColor(RosettaColors.primaryBlue), for: .normal)
}
@@ -1012,8 +1039,11 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
private final class ChatListHeaderBlurView: UIView {
private let edgeEffectView = UIView()
private let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .light))
// Tint overlay shows pinned section background color via gradient mask
private let tintView = UIView()
private let tintMaskView = UIImageView()
// CABackdropLayer captures content behind and applies subtle blur
private var backdropLayer: CALayer?
private let fadeMaskLayer = CAGradientLayer()
private var plainBackgroundColor: UIColor = .black
private var pinnedBackgroundColor: UIColor = .black
@@ -1024,27 +1054,27 @@ private final class ChatListHeaderBlurView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
isUserInteractionEnabled = false
clipsToBounds = true
edgeEffectView.translatesAutoresizingMaskIntoConstraints = false
blurView.translatesAutoresizingMaskIntoConstraints = false
// Backdrop blur layer very subtle (radius 1.0), no colorMatrix
if let backdrop = BackdropLayerHelper.createBackdropLayer() {
backdrop.delegate = BackdropLayerDelegate.shared
BackdropLayerHelper.setScale(backdrop, scale: 0.5)
if let blur = CALayer.blurFilter() {
blur.setValue(1.0 as NSNumber, forKey: "inputRadius")
backdrop.filters = [blur]
}
layer.addSublayer(backdrop)
self.backdropLayer = backdrop
}
addSubview(edgeEffectView)
addSubview(blurView)
NSLayoutConstraint.activate([
edgeEffectView.topAnchor.constraint(equalTo: topAnchor),
edgeEffectView.leadingAnchor.constraint(equalTo: leadingAnchor),
edgeEffectView.trailingAnchor.constraint(equalTo: trailingAnchor),
edgeEffectView.bottomAnchor.constraint(equalTo: bottomAnchor),
blurView.topAnchor.constraint(equalTo: topAnchor),
blurView.leadingAnchor.constraint(equalTo: leadingAnchor),
blurView.trailingAnchor.constraint(equalTo: trailingAnchor),
blurView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
// Tint view with gradient mask (for pinned section color)
tintView.mask = tintMaskView
tintView.alpha = 0.85
addSubview(tintView)
// Gradient fade mask on the whole view
layer.mask = fadeMaskLayer
applyAdaptiveColors()
}
@@ -1060,14 +1090,16 @@ private final class ChatListHeaderBlurView: UIView {
override func layoutSubviews() {
super.layoutSubviews()
backdropLayer?.frame = bounds
tintView.frame = bounds
tintMaskView.frame = bounds
updateFadeMask()
updateTintMask()
}
private func applyAdaptiveColors() {
plainBackgroundColor = UIColor(RosettaColors.Adaptive.background)
pinnedBackgroundColor = UIColor(RosettaColors.Adaptive.pinnedSectionBackground)
blurView.effect = UIBlurEffect(style: .light)
configureTelegramBlurFilters()
updateEdgeEffectColor()
updateChromeOpacity()
}
@@ -1075,25 +1107,20 @@ private final class ChatListHeaderBlurView: UIView {
private func updateEdgeEffectColor() {
let effectivePinnedFraction = isSearchCurrentlyActive ? 0.0 : currentPinnedFraction
let resolved = plainBackgroundColor.mixedWith(pinnedBackgroundColor, alpha: effectivePinnedFraction)
edgeEffectView.backgroundColor = resolved
tintView.backgroundColor = resolved
}
private func updateChromeOpacity() {
let clamped = max(0.0, min(1.0, currentProgress))
edgeEffectView.alpha = clamped
blurView.alpha = 0.85 * clamped
// Backdrop blur is always present its visibility depends on content behind.
// Tint overlay fades in with scroll progress.
tintView.alpha = 0.85 * clamped
}
private func configureTelegramBlurFilters() {
guard let sublayer = blurView.layer.sublayers?.first,
let filters = sublayer.filters else { return }
sublayer.backgroundColor = nil
sublayer.isOpaque = false
let allowedKeys: Set<String> = ["gaussianBlur", "colorSaturate"]
sublayer.filters = filters.filter { filter in
guard let obj = filter as? NSObject else { return true }
return allowedKeys.contains(String(describing: obj))
}
private func updateTintMask() {
let height = max(1, bounds.height)
let edgeSize = min(54.0, height)
tintMaskView.image = VariableBlurEdgeView.generateEdgeGradient(baseHeight: edgeSize)
}
private func updateFadeMask() {
@@ -1118,34 +1145,13 @@ private final class ChatListHeaderBlurView: UIView {
private final class ChatListToolbarGlassCapsuleView: UIView {
private let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterialDark))
private let tintView = UIView()
private let glassView = TelegramGlassUIView(frame: .zero)
override init(frame: CGRect) {
super.init(frame: frame)
isUserInteractionEnabled = false
clipsToBounds = true
blurView.translatesAutoresizingMaskIntoConstraints = false
tintView.translatesAutoresizingMaskIntoConstraints = false
addSubview(blurView)
addSubview(tintView)
NSLayoutConstraint.activate([
blurView.topAnchor.constraint(equalTo: topAnchor),
blurView.leadingAnchor.constraint(equalTo: leadingAnchor),
blurView.trailingAnchor.constraint(equalTo: trailingAnchor),
blurView.bottomAnchor.constraint(equalTo: bottomAnchor),
tintView.topAnchor.constraint(equalTo: topAnchor),
tintView.leadingAnchor.constraint(equalTo: leadingAnchor),
tintView.trailingAnchor.constraint(equalTo: trailingAnchor),
tintView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
layer.borderWidth = 1.0 / UIScreen.main.scale
applyColors()
configureTelegramBlurFilters()
clipsToBounds = false
addSubview(glassView)
}
required init?(coder: NSCoder) {
@@ -1154,34 +1160,9 @@ private final class ChatListToolbarGlassCapsuleView: UIView {
override func layoutSubviews() {
super.layoutSubviews()
applyColors()
layer.cornerRadius = bounds.height * 0.5
configureTelegramBlurFilters()
}
private func applyColors() {
let isDark = traitCollection.userInterfaceStyle == .dark
blurView.effect = UIBlurEffect(style: isDark ? .systemChromeMaterialDark : .systemChromeMaterialLight)
blurView.alpha = isDark ? 0.88 : 0.82
tintView.backgroundColor = isDark
? UIColor(white: 0.0, alpha: 0.34)
: UIColor(white: 1.0, alpha: 0.28)
layer.borderColor = isDark
? UIColor.white.withAlphaComponent(0.12).cgColor
: UIColor.black.withAlphaComponent(0.10).cgColor
}
private func configureTelegramBlurFilters() {
guard let sublayer = blurView.layer.sublayers?.first,
let filters = sublayer.filters else { return }
sublayer.backgroundColor = nil
sublayer.isOpaque = false
let allowedKeys: Set<String> = ["gaussianBlur", "colorSaturate"]
sublayer.filters = filters.filter { filter in
guard let obj = filter as? NSObject else { return true }
return allowedKeys.contains(String(describing: obj))
}
glassView.frame = bounds
glassView.fixedCornerRadius = bounds.height * 0.5
glassView.updateGlass()
}
}
@@ -1271,7 +1252,7 @@ private final class ChatListToolbarDualActionButton: UIView {
addButton.accessibilityLabel = "Add"
composeButton.accessibilityLabel = "Compose"
let iconConfig = UIImage.SymbolConfiguration(pointSize: 16, weight: .medium)
let iconConfig = UIImage.SymbolConfiguration(pointSize: 18, weight: .medium)
let addIcon = UIImage(named: "toolbar-add-chat")?.withRenderingMode(.alwaysTemplate)
?? UIImage(systemName: "plus", withConfiguration: iconConfig)
let composeIcon = UIImage(named: "toolbar-compose")?.withRenderingMode(.alwaysTemplate)
@@ -1301,12 +1282,12 @@ private final class ChatListToolbarDualActionButton: UIView {
addButton.leadingAnchor.constraint(equalTo: leadingAnchor),
addButton.topAnchor.constraint(equalTo: topAnchor),
addButton.bottomAnchor.constraint(equalTo: bottomAnchor),
addButton.widthAnchor.constraint(equalToConstant: 38),
addButton.widthAnchor.constraint(equalToConstant: 44),
composeButton.trailingAnchor.constraint(equalTo: trailingAnchor),
composeButton.topAnchor.constraint(equalTo: topAnchor),
composeButton.bottomAnchor.constraint(equalTo: bottomAnchor),
composeButton.widthAnchor.constraint(equalToConstant: 38),
composeButton.widthAnchor.constraint(equalToConstant: 44),
dividerView.centerXAnchor.constraint(equalTo: centerXAnchor),
dividerView.centerYAnchor.constraint(equalTo: centerYAnchor),
@@ -1314,7 +1295,7 @@ private final class ChatListToolbarDualActionButton: UIView {
dividerView.heightAnchor.constraint(equalToConstant: 20),
heightAnchor.constraint(equalToConstant: 44),
widthAnchor.constraint(equalToConstant: 76),
widthAnchor.constraint(equalToConstant: 88),
])
self.frame = CGRect(origin: .zero, size: intrinsicContentSize)
@@ -1325,7 +1306,7 @@ private final class ChatListToolbarDualActionButton: UIView {
}
override var intrinsicContentSize: CGSize {
CGSize(width: 76, height: 44)
CGSize(width: 88, height: 44)
}
@objc private func handleAddTapped() {