Кастомный header чат-листа в стиле Telegram — glass кнопки, анимация search bar, snap при скролле
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user