Фикс: починил индикацию прочитанных сообщений после миграции на UIKit
This commit is contained in:
@@ -59,6 +59,12 @@ final class ChatDetailViewController: UIViewController {
|
|||||||
// MARK: - Edge Effects
|
// MARK: - Edge Effects
|
||||||
|
|
||||||
private let topEdgeEffectView = VariableBlurEdgeView(frame: .zero)
|
private let topEdgeEffectView = VariableBlurEdgeView(frame: .zero)
|
||||||
|
private let bottomEdgeGradientView: UIView = {
|
||||||
|
let v = UIView()
|
||||||
|
v.isUserInteractionEnabled = false
|
||||||
|
return v
|
||||||
|
}()
|
||||||
|
private let bottomGradientLayer = CAGradientLayer()
|
||||||
|
|
||||||
// MARK: - Init
|
// MARK: - Init
|
||||||
|
|
||||||
@@ -83,6 +89,13 @@ final class ChatDetailViewController: UIViewController {
|
|||||||
setupEdgeEffects()
|
setupEdgeEffects()
|
||||||
wireCellActions()
|
wireCellActions()
|
||||||
wireViewModelSubscriptions()
|
wireViewModelSubscriptions()
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(appDidBecomeActive),
|
||||||
|
name: UIApplication.didBecomeActiveNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
@@ -185,6 +198,17 @@ final class ChatDetailViewController: UIViewController {
|
|||||||
controller.loadViewIfNeeded()
|
controller.loadViewIfNeeded()
|
||||||
|
|
||||||
messageListController = controller
|
messageListController = controller
|
||||||
|
|
||||||
|
// Fix date pill sticky offset for floating header
|
||||||
|
messageListController.topStickyOffset = headerBarHeight
|
||||||
|
|
||||||
|
// Reparent pill overlay: above edge effect (z=40), below toolbar (z=55)
|
||||||
|
let overlay = messageListController.datePillOverlay
|
||||||
|
overlay.removeFromSuperview()
|
||||||
|
overlay.layer.zPosition = 45
|
||||||
|
overlay.frame = view.bounds
|
||||||
|
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
|
view.addSubview(overlay)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Toolbar Setup (glass capsules as direct subviews)
|
// MARK: - Toolbar Setup (glass capsules as direct subviews)
|
||||||
@@ -266,12 +290,45 @@ final class ChatDetailViewController: UIViewController {
|
|||||||
let isDark = traitCollection.userInterfaceStyle == .dark
|
let isDark = traitCollection.userInterfaceStyle == .dark
|
||||||
topEdgeEffectView.setTintColor(isDark ? .black : .white)
|
topEdgeEffectView.setTintColor(isDark ? .black : .white)
|
||||||
view.addSubview(topEdgeEffectView)
|
view.addSubview(topEdgeEffectView)
|
||||||
|
|
||||||
|
// Bottom gradient: simple tint fade (no blur) above composer area
|
||||||
|
bottomEdgeGradientView.layer.zPosition = 40
|
||||||
|
bottomGradientLayer.startPoint = CGPoint(x: 0.5, y: 0)
|
||||||
|
bottomGradientLayer.endPoint = CGPoint(x: 0.5, y: 1)
|
||||||
|
updateBottomGradientColors()
|
||||||
|
bottomEdgeGradientView.layer.addSublayer(bottomGradientLayer)
|
||||||
|
view.addSubview(bottomEdgeGradientView)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateEdgeEffectFrames() {
|
private func updateEdgeEffectFrames() {
|
||||||
let edgeHeight = view.safeAreaInsets.top + headerBarHeight + 14
|
let edgeHeight = view.safeAreaInsets.top + headerBarHeight + 14
|
||||||
topEdgeEffectView.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: edgeHeight)
|
topEdgeEffectView.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: edgeHeight)
|
||||||
topEdgeEffectView.update(size: topEdgeEffectView.bounds.size, edgeSize: 54, contentAlpha: 0.85)
|
topEdgeEffectView.update(size: topEdgeEffectView.bounds.size, edgeSize: 54, contentAlpha: 0.85)
|
||||||
|
|
||||||
|
// Bottom gradient: tint fade below composer, in bottom safe area
|
||||||
|
let bottomSafe = view.safeAreaInsets.bottom
|
||||||
|
if bottomSafe > 0 {
|
||||||
|
bottomEdgeGradientView.isHidden = false
|
||||||
|
bottomEdgeGradientView.frame = CGRect(
|
||||||
|
x: 0,
|
||||||
|
y: view.bounds.height - bottomSafe,
|
||||||
|
width: view.bounds.width,
|
||||||
|
height: bottomSafe
|
||||||
|
)
|
||||||
|
bottomGradientLayer.frame = bottomEdgeGradientView.bounds
|
||||||
|
} else {
|
||||||
|
bottomEdgeGradientView.isHidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateBottomGradientColors() {
|
||||||
|
let bg = traitCollection.userInterfaceStyle == .dark
|
||||||
|
? UIColor.black
|
||||||
|
: UIColor.white
|
||||||
|
bottomGradientLayer.colors = [
|
||||||
|
bg.withAlphaComponent(0).cgColor,
|
||||||
|
bg.withAlphaComponent(1).cgColor,
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||||
@@ -279,6 +336,7 @@ final class ChatDetailViewController: UIViewController {
|
|||||||
if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle {
|
if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle {
|
||||||
let isDark = traitCollection.userInterfaceStyle == .dark
|
let isDark = traitCollection.userInterfaceStyle == .dark
|
||||||
topEdgeEffectView.setTintColor(isDark ? .black : .white)
|
topEdgeEffectView.setTintColor(isDark ? .black : .white)
|
||||||
|
updateBottomGradientColors()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +344,13 @@ final class ChatDetailViewController: UIViewController {
|
|||||||
|
|
||||||
private func wireMessageListCallbacks(_ controller: NativeMessageListController) {
|
private func wireMessageListCallbacks(_ controller: NativeMessageListController) {
|
||||||
controller.onScrollToBottomVisibilityChange = { [weak self] atBottom in
|
controller.onScrollToBottomVisibilityChange = { [weak self] atBottom in
|
||||||
self?.isAtBottom = atBottom
|
guard let self else { return }
|
||||||
|
self.isAtBottom = atBottom
|
||||||
|
SessionManager.shared.resetIdleTimer()
|
||||||
|
self.updateReadEligibility()
|
||||||
|
if atBottom {
|
||||||
|
self.markDialogAsRead()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
controller.onPaginationTrigger = { [weak self] in
|
controller.onPaginationTrigger = { [weak self] in
|
||||||
Task { await self?.viewModel.loadMore() }
|
Task { await self?.viewModel.loadMore() }
|
||||||
@@ -360,6 +424,12 @@ final class ChatDetailViewController: UIViewController {
|
|||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
controller.scrollToBottom(animated: true)
|
controller.scrollToBottom(animated: true)
|
||||||
}
|
}
|
||||||
|
// Mark incoming auto-scrolled messages as read (SwiftUI onNewMessageAutoScroll parity)
|
||||||
|
if isViewActive && !lastIsOutgoing
|
||||||
|
&& !route.isSavedMessages && !route.isSystemAccount {
|
||||||
|
updateReadEligibility()
|
||||||
|
markDialogAsRead()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -798,6 +868,16 @@ final class ChatDetailViewController: UIViewController {
|
|||||||
MessageRepository.shared.setDialogReadEligible(route.publicKey, isEligible: isViewActive && isAtBottom)
|
MessageRepository.shared.setDialogReadEligible(route.publicKey, isEligible: isViewActive && isAtBottom)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func appDidBecomeActive() {
|
||||||
|
guard isViewActive else { return }
|
||||||
|
SessionManager.shared.resetIdleTimer()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in
|
||||||
|
guard let self, self.isViewActive else { return }
|
||||||
|
self.updateReadEligibility()
|
||||||
|
self.markDialogAsRead()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func activateDialog() {
|
private func activateDialog() {
|
||||||
if DialogRepository.shared.dialogs[route.publicKey] != nil {
|
if DialogRepository.shared.dialogs[route.publicKey] != nil {
|
||||||
DialogRepository.shared.ensureDialog(
|
DialogRepository.shared.ensureDialog(
|
||||||
|
|||||||
@@ -143,6 +143,16 @@ final class NativeMessageListController: UIViewController {
|
|||||||
private var dateHideTimer: Timer?
|
private var dateHideTimer: Timer?
|
||||||
private var areDatePillsVisible = false
|
private var areDatePillsVisible = false
|
||||||
private var animatePillFrames = false
|
private var animatePillFrames = false
|
||||||
|
/// Extra top offset for sticky pills when parent has floating header overlay.
|
||||||
|
/// Default 0 preserves behavior for SwiftUI host (where nav bar inflates safe area).
|
||||||
|
var topStickyOffset: CGFloat = 0
|
||||||
|
/// Container for floating date pills — parent VC can reparent for z-ordering.
|
||||||
|
let datePillOverlay: UIView = {
|
||||||
|
let v = UIView()
|
||||||
|
v.isUserInteractionEnabled = false
|
||||||
|
v.backgroundColor = .clear
|
||||||
|
return v
|
||||||
|
}()
|
||||||
|
|
||||||
// MARK: - Empty State (UIKit-managed, animates with keyboard)
|
// MARK: - Empty State (UIKit-managed, animates with keyboard)
|
||||||
private var emptyStateHosting: UIHostingController<EmptyChatContent>?
|
private var emptyStateHosting: UIHostingController<EmptyChatContent>?
|
||||||
@@ -826,10 +836,14 @@ final class NativeMessageListController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func setupFloatingDateHeader() {
|
private func setupFloatingDateHeader() {
|
||||||
|
datePillOverlay.frame = view.bounds
|
||||||
|
datePillOverlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
|
view.addSubview(datePillOverlay)
|
||||||
|
|
||||||
// Pre-create pool of 4 pills (max visible date sections at once).
|
// Pre-create pool of 4 pills (max visible date sections at once).
|
||||||
for _ in 0..<4 {
|
for _ in 0..<4 {
|
||||||
let pill = makeDatePill()
|
let pill = makeDatePill()
|
||||||
view.addSubview(pill.container)
|
datePillOverlay.addSubview(pill.container)
|
||||||
datePillPool.append(pill)
|
datePillPool.append(pill)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -846,7 +860,7 @@ final class NativeMessageListController: UIViewController {
|
|||||||
|
|
||||||
let pillH: CGFloat = 24
|
let pillH: CGFloat = 24
|
||||||
let hPad: CGFloat = 7
|
let hPad: CGFloat = 7
|
||||||
let stickyY = view.safeAreaInsets.top + 8
|
let stickyY = view.safeAreaInsets.top + topStickyOffset + 8
|
||||||
|
|
||||||
// 1. Group visible cells by date → section ranges in screen coords.
|
// 1. Group visible cells by date → section ranges in screen coords.
|
||||||
struct DateSection {
|
struct DateSection {
|
||||||
@@ -887,11 +901,7 @@ final class NativeMessageListController: UIViewController {
|
|||||||
// 2. Expand pool if more sections than pills (short chats spanning many days).
|
// 2. Expand pool if more sections than pills (short chats spanning many days).
|
||||||
while datePillPool.count < sections.count {
|
while datePillPool.count < sections.count {
|
||||||
let pill = makeDatePill()
|
let pill = makeDatePill()
|
||||||
if let composer = composerView {
|
datePillOverlay.addSubview(pill.container)
|
||||||
view.insertSubview(pill.container, belowSubview: composer)
|
|
||||||
} else {
|
|
||||||
view.addSubview(pill.container)
|
|
||||||
}
|
|
||||||
datePillPool.append(pill)
|
datePillPool.append(pill)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -194,11 +194,10 @@ private extension ChatListSearchContent {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Telegram parity: subtitle shows online status, not @username
|
|
||||||
if !isSelf {
|
if !isSelf {
|
||||||
let dialog = DialogRepository.shared.dialogs[user.publicKey]
|
let dialog = DialogRepository.shared.dialogs[user.publicKey]
|
||||||
let isOnline = dialog?.isOnline ?? false
|
let isOnline = dialog?.isOnline ?? false
|
||||||
Text(isOnline ? "online" : "last seen recently")
|
Text(isOnline ? "online" : "offline")
|
||||||
.font(.system(size: 13))
|
.font(.system(size: 13))
|
||||||
.foregroundStyle(isOnline
|
.foregroundStyle(isOnline
|
||||||
? RosettaColors.primaryBlue
|
? RosettaColors.primaryBlue
|
||||||
|
|||||||
@@ -222,8 +222,9 @@ final class ChatListRootViewController: UIViewController, UINavigationController
|
|||||||
editButtonControl.isUserInteractionEnabled = true
|
editButtonControl.isUserInteractionEnabled = true
|
||||||
rightButtonsControl.isUserInteractionEnabled = true
|
rightButtonsControl.isUserInteractionEnabled = true
|
||||||
toolbarTitleView.isUserInteractionEnabled = true
|
toolbarTitleView.isUserInteractionEnabled = true
|
||||||
// Restore search bar position and tear down overlay
|
// Restore search bar position, blur, and tear down overlay
|
||||||
searchHeaderTopConstraint?.constant = headerBarHeight + searchTopSpacing
|
searchHeaderTopConstraint?.constant = headerBarHeight + searchTopSpacing
|
||||||
|
navigationBlurView.isHidden = false
|
||||||
teardownSearchOverlayImmediately()
|
teardownSearchOverlayImmediately()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,7 +309,9 @@ final class ChatListRootViewController: UIViewController, UINavigationController
|
|||||||
searchHeaderView.onActiveChanged = { [weak self] active in
|
searchHeaderView.onActiveChanged = { [weak self] active in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.applySearchExpansion(1.0, animated: true)
|
self.applySearchExpansion(1.0, animated: true)
|
||||||
self.updateNavigationBarBlur(progress: active ? 1.0 : (1.0 - self.lastSearchExpansion))
|
// Hide blur when search active (search bar moves up, no blur needed)
|
||||||
|
self.updateNavigationBarBlur(progress: active ? 0.0 : (1.0 - self.lastSearchExpansion))
|
||||||
|
self.navigationBlurView.isHidden = active
|
||||||
self.animateToolbarForSearch(active: active)
|
self.animateToolbarForSearch(active: active)
|
||||||
self.animateSearchBarPosition(active: active)
|
self.animateSearchBarPosition(active: active)
|
||||||
if active {
|
if active {
|
||||||
@@ -515,7 +518,7 @@ final class ChatListRootViewController: UIViewController, UINavigationController
|
|||||||
view.insertSubview(bg, belowSubview: searchHeaderView)
|
view.insertSubview(bg, belowSubview: searchHeaderView)
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
bg.topAnchor.constraint(equalTo: searchHeaderView.bottomAnchor, constant: searchBottomSpacing),
|
bg.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
bg.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
bg.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
bg.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
bg.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
bg.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
bg.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
@@ -531,12 +534,25 @@ final class ChatListRootViewController: UIViewController, UINavigationController
|
|||||||
hosting.didMove(toParent: self)
|
hosting.didMove(toParent: self)
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
hosting.view.topAnchor.constraint(equalTo: bg.topAnchor),
|
// Content starts below search bar (not behind it)
|
||||||
|
hosting.view.topAnchor.constraint(equalTo: searchHeaderView.bottomAnchor, constant: searchBottomSpacing),
|
||||||
hosting.view.leadingAnchor.constraint(equalTo: bg.leadingAnchor),
|
hosting.view.leadingAnchor.constraint(equalTo: bg.leadingAnchor),
|
||||||
hosting.view.trailingAnchor.constraint(equalTo: bg.trailingAnchor),
|
hosting.view.trailingAnchor.constraint(equalTo: bg.trailingAnchor),
|
||||||
hosting.view.bottomAnchor.constraint(equalTo: bg.bottomAnchor),
|
hosting.view.bottomAnchor.constraint(equalTo: bg.bottomAnchor),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// Edge fade gradient below search bar (Telegram-style)
|
||||||
|
let edgeFade = CAGradientLayer()
|
||||||
|
edgeFade.colors = [
|
||||||
|
UIColor(RosettaColors.Adaptive.background).cgColor,
|
||||||
|
UIColor(RosettaColors.Adaptive.background).withAlphaComponent(0).cgColor,
|
||||||
|
]
|
||||||
|
edgeFade.locations = [0, 1]
|
||||||
|
edgeFade.startPoint = CGPoint(x: 0.5, y: 0)
|
||||||
|
edgeFade.endPoint = CGPoint(x: 0.5, y: 1)
|
||||||
|
edgeFade.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 14)
|
||||||
|
hosting.view.layer.addSublayer(edgeFade)
|
||||||
|
|
||||||
searchOverlayView = bg
|
searchOverlayView = bg
|
||||||
searchContentHosting = hosting
|
searchContentHosting = hosting
|
||||||
|
|
||||||
@@ -1113,6 +1129,7 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
|
|||||||
private(set) var isSearchActive = false
|
private(set) var isSearchActive = false
|
||||||
|
|
||||||
private let capsuleView = UIView()
|
private let capsuleView = UIView()
|
||||||
|
private let capsuleGlass = TelegramGlassUIView(frame: .zero)
|
||||||
private let placeholderStack = UIStackView()
|
private let placeholderStack = UIStackView()
|
||||||
private let placeholderIcon = UIImageView(image: UIImage(systemName: "magnifyingglass"))
|
private let placeholderIcon = UIImageView(image: UIImage(systemName: "magnifyingglass"))
|
||||||
private let placeholderLabel = UILabel()
|
private let placeholderLabel = UILabel()
|
||||||
@@ -1121,9 +1138,11 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
|
|||||||
private let activeIcon = UIImageView(image: UIImage(systemName: "magnifyingglass"))
|
private let activeIcon = UIImageView(image: UIImage(systemName: "magnifyingglass"))
|
||||||
private let textField = UITextField()
|
private let textField = UITextField()
|
||||||
private let inlineClearButton = UIButton(type: .system)
|
private let inlineClearButton = UIButton(type: .system)
|
||||||
private let cancelButton = UIButton(type: .system)
|
private let cancelButton = UIButton(type: .custom)
|
||||||
|
private let cancelGlass = TelegramGlassUIView(frame: .zero)
|
||||||
|
|
||||||
private var cancelWidthConstraint: NSLayoutConstraint!
|
private var cancelWidthConstraint: NSLayoutConstraint!
|
||||||
|
private var capsuleTrailingConstraint: NSLayoutConstraint!
|
||||||
private var suppressQueryCallback = false
|
private var suppressQueryCallback = false
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
@@ -1142,6 +1161,17 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
|
|||||||
applyColors()
|
applyColors()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
// Update glass frames and corner radii
|
||||||
|
capsuleGlass.frame = capsuleView.bounds
|
||||||
|
capsuleGlass.fixedCornerRadius = capsuleView.bounds.height * 0.5
|
||||||
|
capsuleGlass.updateGlass()
|
||||||
|
// cancelGlass uses constraints (sibling of cancelButton), just update glass
|
||||||
|
cancelGlass.fixedCornerRadius = cancelButton.bounds.height * 0.5
|
||||||
|
cancelGlass.updateGlass()
|
||||||
|
}
|
||||||
|
|
||||||
func endSearch(animated: Bool, clearText: Bool) {
|
func endSearch(animated: Bool, clearText: Bool) {
|
||||||
setSearchActive(false, animated: animated, clearText: clearText)
|
setSearchActive(false, animated: animated, clearText: clearText)
|
||||||
}
|
}
|
||||||
@@ -1158,7 +1188,9 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
|
|||||||
func updateExpansionProgress(_ progress: CGFloat) {
|
func updateExpansionProgress(_ progress: CGFloat) {
|
||||||
let currentHeight = searchBarHeight * progress
|
let currentHeight = searchBarHeight * progress
|
||||||
// Dynamic corner radius — Telegram: height * 0.5
|
// Dynamic corner radius — Telegram: height * 0.5
|
||||||
capsuleView.layer.cornerRadius = max(0, currentHeight * 0.5)
|
let radius = max(0, currentHeight * 0.5)
|
||||||
|
capsuleGlass.fixedCornerRadius = radius
|
||||||
|
capsuleGlass.updateGlass()
|
||||||
|
|
||||||
// Telegram inner content alpha: 0 until 77%, then ramps to 1
|
// Telegram inner content alpha: 0 until 77%, then ramps to 1
|
||||||
let innerAlpha = max(0.0, min(1.0, (progress - 0.77) / 0.23))
|
let innerAlpha = max(0.0, min(1.0, (progress - 0.77) / 0.23))
|
||||||
@@ -1174,8 +1206,12 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
|
|||||||
|
|
||||||
capsuleView.translatesAutoresizingMaskIntoConstraints = false
|
capsuleView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
capsuleView.layer.cornerRadius = 22
|
capsuleView.layer.cornerRadius = 22
|
||||||
capsuleView.layer.borderWidth = 0
|
|
||||||
capsuleView.clipsToBounds = true
|
capsuleView.clipsToBounds = true
|
||||||
|
// Glass background for capsule (Telegram active search material)
|
||||||
|
capsuleGlass.fixedCornerRadius = 22
|
||||||
|
capsuleGlass.isHidden = true
|
||||||
|
capsuleGlass.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
|
capsuleView.insertSubview(capsuleGlass, at: 0)
|
||||||
addSubview(capsuleView)
|
addSubview(capsuleView)
|
||||||
|
|
||||||
placeholderStack.translatesAutoresizingMaskIntoConstraints = false
|
placeholderStack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
@@ -1216,13 +1252,20 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
|
|||||||
activeStack.addArrangedSubview(inlineClearButton)
|
activeStack.addArrangedSubview(inlineClearButton)
|
||||||
capsuleView.addSubview(activeStack)
|
capsuleView.addSubview(activeStack)
|
||||||
|
|
||||||
// Telegram-style circular X button (replaces "Cancel" text)
|
// Glass background for X button (sibling, positioned behind)
|
||||||
|
cancelGlass.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
cancelGlass.fixedCornerRadius = 22
|
||||||
|
cancelGlass.isUserInteractionEnabled = false
|
||||||
|
cancelGlass.isHidden = true
|
||||||
|
addSubview(cancelGlass)
|
||||||
|
|
||||||
|
// Telegram-style circular X button: 44pt, glass material
|
||||||
cancelButton.translatesAutoresizingMaskIntoConstraints = false
|
cancelButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
let xConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
|
let xConfig = UIImage.SymbolConfiguration(pointSize: 16, weight: .medium)
|
||||||
cancelButton.setImage(UIImage(systemName: "xmark", withConfiguration: xConfig), for: .normal)
|
cancelButton.setImage(UIImage(systemName: "xmark", withConfiguration: xConfig), for: .normal)
|
||||||
cancelButton.setTitle(nil, for: .normal)
|
cancelButton.setTitle(nil, for: .normal)
|
||||||
cancelButton.layer.cornerRadius = 18
|
cancelButton.clipsToBounds = false
|
||||||
cancelButton.clipsToBounds = true
|
cancelButton.backgroundColor = .clear
|
||||||
cancelButton.addTarget(self, action: #selector(handleCancelTapped), for: .touchUpInside)
|
cancelButton.addTarget(self, action: #selector(handleCancelTapped), for: .touchUpInside)
|
||||||
addSubview(cancelButton)
|
addSubview(cancelButton)
|
||||||
|
|
||||||
@@ -1230,38 +1273,45 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
|
|||||||
capsuleView.addGestureRecognizer(tap)
|
capsuleView.addGestureRecognizer(tap)
|
||||||
|
|
||||||
cancelWidthConstraint = cancelButton.widthAnchor.constraint(equalToConstant: 0)
|
cancelWidthConstraint = cancelButton.widthAnchor.constraint(equalToConstant: 0)
|
||||||
|
// Fix 1: animate gap — 0 when inactive (symmetric), -8 when active
|
||||||
|
let trailing = capsuleView.trailingAnchor.constraint(equalTo: cancelButton.leadingAnchor, constant: 0)
|
||||||
|
capsuleTrailingConstraint = trailing
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
capsuleView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
capsuleView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
capsuleView.topAnchor.constraint(equalTo: topAnchor),
|
capsuleView.topAnchor.constraint(equalTo: topAnchor),
|
||||||
capsuleView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
capsuleView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
capsuleView.trailingAnchor.constraint(equalTo: cancelButton.leadingAnchor, constant: -8),
|
trailing,
|
||||||
|
|
||||||
cancelButton.trailingAnchor.constraint(equalTo: trailingAnchor),
|
cancelButton.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
cancelButton.centerYAnchor.constraint(equalTo: centerYAnchor),
|
cancelButton.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||||
cancelButton.heightAnchor.constraint(equalToConstant: 36),
|
cancelButton.heightAnchor.constraint(equalToConstant: 44),
|
||||||
cancelWidthConstraint,
|
cancelWidthConstraint,
|
||||||
|
|
||||||
|
// Glass behind X button (same frame)
|
||||||
|
cancelGlass.topAnchor.constraint(equalTo: cancelButton.topAnchor),
|
||||||
|
cancelGlass.leadingAnchor.constraint(equalTo: cancelButton.leadingAnchor),
|
||||||
|
cancelGlass.trailingAnchor.constraint(equalTo: cancelButton.trailingAnchor),
|
||||||
|
cancelGlass.bottomAnchor.constraint(equalTo: cancelButton.bottomAnchor),
|
||||||
|
|
||||||
placeholderStack.centerXAnchor.constraint(equalTo: capsuleView.centerXAnchor),
|
placeholderStack.centerXAnchor.constraint(equalTo: capsuleView.centerXAnchor),
|
||||||
placeholderStack.centerYAnchor.constraint(equalTo: capsuleView.centerYAnchor),
|
placeholderStack.centerYAnchor.constraint(equalTo: capsuleView.centerYAnchor),
|
||||||
|
|
||||||
activeStack.leadingAnchor.constraint(equalTo: capsuleView.leadingAnchor, constant: 12),
|
// Fix 4: leading 8pt (Telegram parity)
|
||||||
|
activeStack.leadingAnchor.constraint(equalTo: capsuleView.leadingAnchor, constant: 8),
|
||||||
activeStack.trailingAnchor.constraint(equalTo: capsuleView.trailingAnchor, constant: -10),
|
activeStack.trailingAnchor.constraint(equalTo: capsuleView.trailingAnchor, constant: -10),
|
||||||
activeStack.topAnchor.constraint(equalTo: capsuleView.topAnchor),
|
activeStack.topAnchor.constraint(equalTo: capsuleView.topAnchor),
|
||||||
activeStack.bottomAnchor.constraint(equalTo: capsuleView.bottomAnchor),
|
activeStack.bottomAnchor.constraint(equalTo: capsuleView.bottomAnchor),
|
||||||
|
|
||||||
|
// Fix 2: fixed width prevents stretching to 44pt height
|
||||||
|
activeIcon.widthAnchor.constraint(equalToConstant: 20),
|
||||||
inlineClearButton.widthAnchor.constraint(equalToConstant: 24),
|
inlineClearButton.widthAnchor.constraint(equalToConstant: 24),
|
||||||
inlineClearButton.heightAnchor.constraint(equalToConstant: 24),
|
inlineClearButton.heightAnchor.constraint(equalToConstant: 24),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyColors() {
|
private func applyColors() {
|
||||||
// Telegram exact colors from DefaultDarkPresentationTheme / DefaultDayPresentationTheme
|
|
||||||
let isDark = traitCollection.userInterfaceStyle == .dark
|
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
|
// inputPlaceholderTextColor: dark #8f8f8f, light #8e8e93
|
||||||
let placeholderColor = isDark
|
let placeholderColor = isDark
|
||||||
? UIColor(red: 0x8f/255.0, green: 0x8f/255.0, blue: 0x8f/255.0, alpha: 1.0)
|
? UIColor(red: 0x8f/255.0, green: 0x8f/255.0, blue: 0x8f/255.0, alpha: 1.0)
|
||||||
@@ -1272,19 +1322,44 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
|
|||||||
// inputTextColor: dark #ffffff, light #000000
|
// inputTextColor: dark #ffffff, light #000000
|
||||||
textField.textColor = isDark ? .white : .black
|
textField.textColor = isDark ? .white : .black
|
||||||
inlineClearButton.tintColor = placeholderColor
|
inlineClearButton.tintColor = placeholderColor
|
||||||
// Telegram X button: dark circle background, white icon
|
// X button icon: white on dark, black on light
|
||||||
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
|
cancelButton.tintColor = isDark ? .white : .black
|
||||||
|
// Apply correct background for current state
|
||||||
|
applyCapsuleBackground()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inactive: solid #272728. Active: TelegramGlassUIView (glass material).
|
||||||
|
private func applyCapsuleBackground() {
|
||||||
|
let isDark = traitCollection.userInterfaceStyle == .dark
|
||||||
|
let solidFill = 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)
|
||||||
|
|
||||||
|
if isSearchActive {
|
||||||
|
// Glass material (Telegram active search)
|
||||||
|
capsuleView.backgroundColor = .clear
|
||||||
|
capsuleGlass.isHidden = false
|
||||||
|
capsuleGlass.updateGlass()
|
||||||
|
cancelGlass.isHidden = false
|
||||||
|
cancelGlass.updateGlass()
|
||||||
|
} else {
|
||||||
|
// Solid color (Telegram inactive search)
|
||||||
|
capsuleView.backgroundColor = solidFill
|
||||||
|
capsuleGlass.isHidden = true
|
||||||
|
cancelGlass.isHidden = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateVisualState(animated: Bool) {
|
private func updateVisualState(animated: Bool) {
|
||||||
|
// Switch capsule background: solid ↔ glass
|
||||||
|
applyCapsuleBackground()
|
||||||
|
|
||||||
let updates = {
|
let updates = {
|
||||||
self.placeholderStack.alpha = self.isSearchActive ? 0 : 1
|
self.placeholderStack.alpha = self.isSearchActive ? 0 : 1
|
||||||
self.activeStack.alpha = self.isSearchActive ? 1 : 0
|
self.activeStack.alpha = self.isSearchActive ? 1 : 0
|
||||||
self.cancelButton.alpha = self.isSearchActive ? 1 : 0
|
self.cancelButton.alpha = self.isSearchActive ? 1 : 0
|
||||||
self.cancelWidthConstraint.constant = self.isSearchActive ? 36 : 0
|
self.cancelWidthConstraint.constant = self.isSearchActive ? 44 : 0
|
||||||
|
self.capsuleTrailingConstraint.constant = self.isSearchActive ? -8 : 0
|
||||||
self.layoutIfNeeded()
|
self.layoutIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user