Фикс: кастомный header на экране Request Chats — glass chevron, separator, full-width swipe back
This commit is contained in:
1290
Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift
Normal file
1290
Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift
Normal file
File diff suppressed because it is too large
Load Diff
@@ -82,7 +82,7 @@ final class RequestChatsController: UIViewController {
|
|||||||
[weak self] cell, indexPath, dialog in
|
[weak self] cell, indexPath, dialog in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
cell.configure(with: dialog, isSyncing: self.isSyncing)
|
cell.configure(with: dialog, isSyncing: self.isSyncing)
|
||||||
cell.setSeparatorHidden(indexPath.item == 0)
|
cell.setSeparatorHidden(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ final class ChatListCollectionController: UIViewController {
|
|||||||
|
|
||||||
private var collectionView: UICollectionView!
|
private var collectionView: UICollectionView!
|
||||||
private var dataSource: UICollectionViewDiffableDataSource<Section, String>!
|
private var dataSource: UICollectionViewDiffableDataSource<Section, String>!
|
||||||
|
/// Overscroll background — fills rubber-band area with pinnedItemBackgroundColor (Telegram parity)
|
||||||
|
private let overscrollBackgroundView = UIView()
|
||||||
private var cellRegistration: UICollectionView.CellRegistration<ChatListCell, Dialog>!
|
private var cellRegistration: UICollectionView.CellRegistration<ChatListCell, Dialog>!
|
||||||
private var requestsCellRegistration: UICollectionView.CellRegistration<ChatListRequestsCell, Int>!
|
private var requestsCellRegistration: UICollectionView.CellRegistration<ChatListRequestsCell, Int>!
|
||||||
private let floatingTabBarTotalHeight: CGFloat = 72
|
private let floatingTabBarTotalHeight: CGFloat = 72
|
||||||
@@ -94,6 +96,11 @@ final class ChatListCollectionController: UIViewController {
|
|||||||
applyInsets()
|
applyInsets()
|
||||||
view.addSubview(collectionView)
|
view.addSubview(collectionView)
|
||||||
|
|
||||||
|
// Overscroll background — behind cells, shows pinnedItemBackgroundColor on pull-down bounce
|
||||||
|
overscrollBackgroundView.isUserInteractionEnabled = false
|
||||||
|
overscrollBackgroundView.isHidden = true
|
||||||
|
collectionView.insertSubview(overscrollBackgroundView, at: 0)
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
|
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
@@ -155,6 +162,34 @@ final class ChatListCollectionController: UIViewController {
|
|||||||
reportPinnedHeaderFraction()
|
reportPinnedHeaderFraction()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func updateOverscrollBackground() {
|
||||||
|
let hasPin = !pinnedDialogs.isEmpty
|
||||||
|
overscrollBackgroundView.isHidden = !hasPin
|
||||||
|
guard hasPin, collectionView != nil else { return }
|
||||||
|
|
||||||
|
let isDark = traitCollection.userInterfaceStyle == .dark
|
||||||
|
overscrollBackgroundView.backgroundColor = isDark
|
||||||
|
? UIColor(red: 0x1C / 255, green: 0x1C / 255, blue: 0x1D / 255, alpha: 1)
|
||||||
|
: UIColor(red: 0xF7 / 255, green: 0xF7 / 255, blue: 0xF7 / 255, alpha: 1)
|
||||||
|
|
||||||
|
let offset = collectionView.contentOffset.y
|
||||||
|
let insetTop = collectionView.contentInset.top
|
||||||
|
let overscrollAmount = -(offset + insetTop)
|
||||||
|
let width = collectionView.bounds.width
|
||||||
|
|
||||||
|
if overscrollAmount > 0 {
|
||||||
|
overscrollBackgroundView.frame = CGRect(
|
||||||
|
x: 0, y: offset,
|
||||||
|
width: width, height: insetTop + overscrollAmount
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
overscrollBackgroundView.frame = CGRect(
|
||||||
|
x: 0, y: -insetTop,
|
||||||
|
width: width, height: insetTop
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func schedulePinnedHeaderFractionReport(force: Bool = false) {
|
private func schedulePinnedHeaderFractionReport(force: Bool = false) {
|
||||||
if isPinnedFractionReportScheduled { return }
|
if isPinnedFractionReportScheduled { return }
|
||||||
isPinnedFractionReportScheduled = true
|
isPinnedFractionReportScheduled = true
|
||||||
@@ -171,23 +206,33 @@ final class ChatListCollectionController: UIViewController {
|
|||||||
collectionView.window != nil,
|
collectionView.window != nil,
|
||||||
!pinnedDialogs.isEmpty else { return 0.0 }
|
!pinnedDialogs.isEmpty else { return 0.0 }
|
||||||
|
|
||||||
var maxPinnedOffset: CGFloat = 0.0
|
// Telegram: itemNode.frame is in VISUAL space (changes with scroll).
|
||||||
|
// UICollectionView cell.frame is in CONTENT space (static).
|
||||||
|
// Convert: visibleMaxY = cell.frame.maxY - contentOffset.y
|
||||||
|
let offset = collectionView.contentOffset.y
|
||||||
|
var maxPinnedVisibleOffset: CGFloat = 0.0
|
||||||
|
var foundAny = false
|
||||||
|
|
||||||
for cell in collectionView.visibleCells {
|
for cell in collectionView.visibleCells {
|
||||||
guard let indexPath = collectionView.indexPath(for: cell),
|
guard let indexPath = collectionView.indexPath(for: cell),
|
||||||
sectionForIndexPath(indexPath) == .pinned else {
|
sectionForIndexPath(indexPath) == .pinned else {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
maxPinnedOffset = max(maxPinnedOffset, cell.frame.maxY)
|
let visibleMaxY = cell.frame.maxY - offset
|
||||||
|
maxPinnedVisibleOffset = max(maxPinnedVisibleOffset, visibleMaxY)
|
||||||
|
foundAny = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !foundAny { return 0.0 }
|
||||||
|
|
||||||
let viewportInsetTop = collectionView.contentInset.top
|
let viewportInsetTop = collectionView.contentInset.top
|
||||||
guard viewportInsetTop > 0 else { return 0.0 }
|
guard viewportInsetTop > 0 else { return 0.0 }
|
||||||
|
|
||||||
if maxPinnedOffset >= viewportInsetTop {
|
if maxPinnedVisibleOffset >= viewportInsetTop {
|
||||||
return 1.0
|
return 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
return max(0.0, min(1.0, maxPinnedOffset / viewportInsetTop))
|
return max(0.0, min(1.0, maxPinnedVisibleOffset / viewportInsetTop))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reportPinnedHeaderFraction(force: Bool = false) {
|
private func reportPinnedHeaderFraction(force: Bool = false) {
|
||||||
@@ -216,14 +261,18 @@ final class ChatListCollectionController: UIViewController {
|
|||||||
let section = NSCollectionLayoutSection.list(using: listConfig, layoutEnvironment: environment)
|
let section = NSCollectionLayoutSection.list(using: listConfig, layoutEnvironment: environment)
|
||||||
section.interGroupSpacing = 0
|
section.interGroupSpacing = 0
|
||||||
|
|
||||||
// Add pinned section background decoration
|
// Add pinned section background decoration to pinned AND requests sections
|
||||||
|
// (requests sit above pinned, so they share the same gray background)
|
||||||
if let self,
|
if let self,
|
||||||
sectionIndex < self.dataSource?.snapshot().sectionIdentifiers.count ?? 0,
|
sectionIndex < self.dataSource?.snapshot().sectionIdentifiers.count ?? 0,
|
||||||
self.dataSource?.snapshot().sectionIdentifiers[sectionIndex] == .pinned {
|
!self.pinnedDialogs.isEmpty {
|
||||||
let bgItem = NSCollectionLayoutDecorationItem.background(
|
let sectionId = self.dataSource?.snapshot().sectionIdentifiers[sectionIndex]
|
||||||
elementKind: PinnedSectionBackgroundView.elementKind
|
if sectionId == .pinned || sectionId == .requests {
|
||||||
)
|
let bgItem = NSCollectionLayoutDecorationItem.background(
|
||||||
section.decorationItems = [bgItem]
|
elementKind: PinnedSectionBackgroundView.elementKind
|
||||||
|
)
|
||||||
|
section.decorationItems = [bgItem]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return section
|
return section
|
||||||
@@ -262,9 +311,8 @@ final class ChatListCollectionController: UIViewController {
|
|||||||
|
|
||||||
requestsCellRegistration = UICollectionView.CellRegistration<ChatListRequestsCell, Int> {
|
requestsCellRegistration = UICollectionView.CellRegistration<ChatListRequestsCell, Int> {
|
||||||
[weak self] cell, _, count in
|
[weak self] cell, _, count in
|
||||||
let hasPinned = !(self?.pinnedDialogs.isEmpty ?? true)
|
// Always show separator under requests row
|
||||||
// Requests row separator should be hidden when pinned section exists.
|
cell.configure(count: count, showBottomSeparator: true)
|
||||||
cell.configure(count: count, showBottomSeparator: !hasPinned)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,6 +401,7 @@ final class ChatListCollectionController: UIViewController {
|
|||||||
|
|
||||||
// Notify host immediately so top chrome reacts in the same frame.
|
// Notify host immediately so top chrome reacts in the same frame.
|
||||||
onPinnedStateChange?(!pinned.isEmpty)
|
onPinnedStateChange?(!pinned.isEmpty)
|
||||||
|
updateOverscrollBackground()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Directly reconfigure only visible cells — no snapshot rebuild, no animation.
|
/// Directly reconfigure only visible cells — no snapshot rebuild, no animation.
|
||||||
@@ -366,7 +415,7 @@ final class ChatListCollectionController: UIViewController {
|
|||||||
let typingUsers = typingDialogs[dialog.opponentKey]
|
let typingUsers = typingDialogs[dialog.opponentKey]
|
||||||
chatCell.configure(with: dialog, isSyncing: isSyncing, typingUsers: typingUsers)
|
chatCell.configure(with: dialog, isSyncing: isSyncing, typingUsers: typingUsers)
|
||||||
} else if let reqCell = cell as? ChatListRequestsCell {
|
} else if let reqCell = cell as? ChatListRequestsCell {
|
||||||
reqCell.configure(count: requestsCount, showBottomSeparator: pinnedDialogs.isEmpty)
|
reqCell.configure(count: requestsCount, showBottomSeparator: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -474,6 +523,7 @@ extension ChatListCollectionController: UICollectionViewDelegate {
|
|||||||
onScrollOffsetChange?(expansion)
|
onScrollOffsetChange?(expansion)
|
||||||
}
|
}
|
||||||
reportPinnedHeaderFraction()
|
reportPinnedHeaderFraction()
|
||||||
|
updateOverscrollBackground()
|
||||||
}
|
}
|
||||||
|
|
||||||
func scrollViewWillEndDragging(
|
func scrollViewWillEndDragging(
|
||||||
|
|||||||
@@ -66,10 +66,8 @@ private final class ChatListUIKitCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setDetailPresented(_ presented: Bool) {
|
func setDetailPresented(_ presented: Bool) {
|
||||||
DispatchQueue.main.async {
|
if self.isDetailPresented.wrappedValue != presented {
|
||||||
if self.isDetailPresented.wrappedValue != presented {
|
self.isDetailPresented.wrappedValue = presented
|
||||||
self.isDetailPresented.wrappedValue = presented
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,9 +195,10 @@ final class ChatListRootViewController: UIViewController, UINavigationController
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateNavigationBlurHeight() {
|
private func updateNavigationBlurHeight() {
|
||||||
let headerBottom = headerTotalHeight
|
// Telegram: edgeEffectHeight = componentHeight + 14 - searchBarExpansion
|
||||||
let expandedSearchHeight = searchChromeHeight * lastSearchExpansion
|
// Result: edge effect covers toolbar area + 14pt ONLY (not search bar).
|
||||||
navigationBlurHeightConstraint?.constant = max(0, headerBottom + expandedSearchHeight + 14.0)
|
// Search bar has its own background, doesn't need blur coverage.
|
||||||
|
navigationBlurHeightConstraint?.constant = max(0, headerTotalHeight + 14.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillDisappear(_ animated: Bool) {
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
@@ -604,16 +603,9 @@ final class ChatListRootViewController: UIViewController, UINavigationController
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func openChat(route: ChatRoute) {
|
private func openChat(route: ChatRoute) {
|
||||||
let detail = ChatDetailView(
|
onDetailPresentedChanged?(true) // hide tab bar BEFORE push animation
|
||||||
route: route,
|
let detail = ChatDetailViewController(route: route)
|
||||||
onPresentedChange: { [weak self] presented in
|
navigationController?.pushViewController(detail, animated: true)
|
||||||
self?.onDetailPresentedChanged?(presented)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
let hosting = UIHostingController(rootView: detail.id(route.publicKey))
|
|
||||||
hosting.view.backgroundColor = UIColor(RosettaColors.Adaptive.background)
|
|
||||||
navigationController?.pushViewController(hosting, animated: true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openRequests() {
|
private func openRequests() {
|
||||||
@@ -705,9 +697,13 @@ final class ChatListRootViewController: UIViewController, UINavigationController
|
|||||||
willShow viewController: UIViewController,
|
willShow viewController: UIViewController,
|
||||||
animated: Bool
|
animated: Bool
|
||||||
) {
|
) {
|
||||||
// Show standard nav bar for pushed screens, hide on chat list
|
let hideNavBar = viewController === self
|
||||||
let isChatList = viewController === self
|
|| viewController is ChatDetailViewController
|
||||||
navigationController.setNavigationBarHidden(isChatList, animated: animated)
|
|| viewController is RequestChatsUIKitShellController
|
||||||
|
navigationController.setNavigationBarHidden(hideNavBar, animated: animated)
|
||||||
|
|
||||||
|
let isPresented = navigationController.viewControllers.count > 1
|
||||||
|
onDetailPresentedChanged?(isPresented)
|
||||||
}
|
}
|
||||||
|
|
||||||
func navigationController(
|
func navigationController(
|
||||||
@@ -729,6 +725,14 @@ final class RequestChatsUIKitShellController: UIViewController {
|
|||||||
private let requestsController = RequestChatsController()
|
private let requestsController = RequestChatsController()
|
||||||
private var observationTask: Task<Void, Never>?
|
private var observationTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
// Custom header elements (direct subviews — glass needs this)
|
||||||
|
private let headerBarHeight: CGFloat = 44
|
||||||
|
private let backButton = UIControl()
|
||||||
|
private let backGlassBackground = ChatListToolbarGlassCapsuleView()
|
||||||
|
private let chevronLayer = CAShapeLayer()
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private var addedFullWidthGesture = false
|
||||||
|
|
||||||
init(viewModel: ChatListViewModel) {
|
init(viewModel: ChatListViewModel) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
@@ -741,13 +745,14 @@ final class RequestChatsUIKitShellController: UIViewController {
|
|||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = UIColor(RosettaColors.Adaptive.background)
|
view.backgroundColor = UIColor(RosettaColors.Adaptive.background)
|
||||||
title = "Request Chats"
|
|
||||||
|
setupCustomHeader()
|
||||||
|
|
||||||
addChild(requestsController)
|
addChild(requestsController)
|
||||||
view.addSubview(requestsController.view)
|
view.addSubview(requestsController.view)
|
||||||
requestsController.view.translatesAutoresizingMaskIntoConstraints = false
|
requestsController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
requestsController.view.topAnchor.constraint(equalTo: view.topAnchor),
|
requestsController.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: headerBarHeight),
|
||||||
requestsController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
requestsController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
requestsController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
requestsController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
requestsController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
requestsController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
@@ -765,10 +770,117 @@ final class RequestChatsUIKitShellController: UIViewController {
|
|||||||
render()
|
render()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
setupFullWidthSwipeBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLayoutSubviews() {
|
||||||
|
super.viewDidLayoutSubviews()
|
||||||
|
layoutCustomHeader()
|
||||||
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
observationTask?.cancel()
|
observationTask?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Custom Header
|
||||||
|
|
||||||
|
private func setupCustomHeader() {
|
||||||
|
// Back button — glass capsule + SVG chevron (TelegramIconPath.backChevron)
|
||||||
|
backGlassBackground.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
backButton.addSubview(backGlassBackground)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
backGlassBackground.topAnchor.constraint(equalTo: backButton.topAnchor),
|
||||||
|
backGlassBackground.leadingAnchor.constraint(equalTo: backButton.leadingAnchor),
|
||||||
|
backGlassBackground.trailingAnchor.constraint(equalTo: backButton.trailingAnchor),
|
||||||
|
backGlassBackground.bottomAnchor.constraint(equalTo: backButton.bottomAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Chevron from SVG path (viewBox 11×20, same as ChatDetailView)
|
||||||
|
let viewBox = CGSize(width: 11, height: 20)
|
||||||
|
let iconSize = CGSize(width: 11, height: 20)
|
||||||
|
var parser = SVGPathParser(pathData: TelegramIconPath.backChevron)
|
||||||
|
let rawPath = parser.parse()
|
||||||
|
let buttonSize: CGFloat = 44
|
||||||
|
var transform = CGAffineTransform(
|
||||||
|
scaleX: iconSize.width / viewBox.width,
|
||||||
|
y: iconSize.height / viewBox.height
|
||||||
|
).translatedBy(
|
||||||
|
x: (buttonSize - iconSize.width) / 2 * (viewBox.width / iconSize.width),
|
||||||
|
y: (buttonSize - iconSize.height) / 2 * (viewBox.height / iconSize.height)
|
||||||
|
)
|
||||||
|
chevronLayer.path = rawPath.copy(using: &transform)
|
||||||
|
chevronLayer.fillColor = UIColor(RosettaColors.Adaptive.text).cgColor
|
||||||
|
chevronLayer.frame = CGRect(x: 0, y: 0, width: buttonSize, height: buttonSize)
|
||||||
|
backButton.layer.addSublayer(chevronLayer)
|
||||||
|
|
||||||
|
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
|
||||||
|
backButton.layer.zPosition = 55
|
||||||
|
view.addSubview(backButton)
|
||||||
|
|
||||||
|
// Title
|
||||||
|
titleLabel.text = "Request Chats"
|
||||||
|
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
|
titleLabel.textColor = UIColor(RosettaColors.Adaptive.text)
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
titleLabel.layer.zPosition = 55
|
||||||
|
view.addSubview(titleLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func layoutCustomHeader() {
|
||||||
|
let statusBarHeight = view.safeAreaInsets.top
|
||||||
|
let centerY = statusBarHeight + headerBarHeight * 0.5
|
||||||
|
let sideInset: CGFloat = 16
|
||||||
|
let buttonSize: CGFloat = 44
|
||||||
|
|
||||||
|
backButton.frame = CGRect(
|
||||||
|
x: sideInset,
|
||||||
|
y: centerY - buttonSize * 0.5,
|
||||||
|
width: buttonSize,
|
||||||
|
height: buttonSize
|
||||||
|
)
|
||||||
|
|
||||||
|
let titleSize = titleLabel.intrinsicContentSize
|
||||||
|
titleLabel.frame = CGRect(
|
||||||
|
x: (view.bounds.width - titleSize.width) * 0.5,
|
||||||
|
y: centerY - titleSize.height * 0.5,
|
||||||
|
width: titleSize.width,
|
||||||
|
height: titleSize.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func backTapped() {
|
||||||
|
navigationController?.popViewController(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||||
|
super.traitCollectionDidChange(previousTraitCollection)
|
||||||
|
chevronLayer.fillColor = UIColor(RosettaColors.Adaptive.text).cgColor
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Full-Width Swipe Back
|
||||||
|
|
||||||
|
private func setupFullWidthSwipeBack() {
|
||||||
|
guard !addedFullWidthGesture else { return }
|
||||||
|
addedFullWidthGesture = true
|
||||||
|
|
||||||
|
guard let nav = navigationController,
|
||||||
|
let edgeGesture = nav.interactivePopGestureRecognizer,
|
||||||
|
let targets = edgeGesture.value(forKey: "targets") as? NSArray,
|
||||||
|
targets.count > 0
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
edgeGesture.isEnabled = true
|
||||||
|
|
||||||
|
let fullWidthGesture = UIPanGestureRecognizer()
|
||||||
|
fullWidthGesture.setValue(targets, forKey: "targets")
|
||||||
|
fullWidthGesture.delegate = self
|
||||||
|
nav.view.addGestureRecognizer(fullWidthGesture)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Observation
|
||||||
|
|
||||||
private func startObservationLoop() {
|
private func startObservationLoop() {
|
||||||
observationTask?.cancel()
|
observationTask?.cancel()
|
||||||
observationTask = Task { @MainActor [weak self] in
|
observationTask = Task { @MainActor [weak self] in
|
||||||
@@ -797,6 +909,23 @@ final class RequestChatsUIKitShellController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - UIGestureRecognizerDelegate (full-width swipe back)
|
||||||
|
|
||||||
|
extension RequestChatsUIKitShellController: UIGestureRecognizerDelegate {
|
||||||
|
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||||
|
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return false }
|
||||||
|
let velocity = pan.velocity(in: pan.view)
|
||||||
|
return velocity.x > 0 && abs(velocity.x) > abs(velocity.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
func gestureRecognizer(
|
||||||
|
_ gestureRecognizer: UIGestureRecognizer,
|
||||||
|
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
|
||||||
|
) -> Bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
|
private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
|
||||||
|
|
||||||
var onQueryChanged: ((String) -> Void)?
|
var onQueryChanged: ((String) -> Void)?
|
||||||
@@ -1039,12 +1168,12 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
|
|||||||
|
|
||||||
private final class ChatListHeaderBlurView: UIView {
|
private final class ChatListHeaderBlurView: UIView {
|
||||||
|
|
||||||
// Tint overlay — shows pinned section background color via gradient mask
|
// Telegram EdgeEffectView port:
|
||||||
|
// 1. CABackdropLayer — variable blur of content behind (radius 1.0)
|
||||||
|
// 2. Tint overlay — color changes with pinnedFraction, alpha 0.85, gradient-masked
|
||||||
private let tintView = UIView()
|
private let tintView = UIView()
|
||||||
private let tintMaskView = UIImageView()
|
private let tintMaskView = UIImageView()
|
||||||
// CABackdropLayer — captures content behind and applies subtle blur
|
|
||||||
private var backdropLayer: CALayer?
|
private var backdropLayer: CALayer?
|
||||||
private let fadeMaskLayer = CAGradientLayer()
|
|
||||||
private var plainBackgroundColor: UIColor = .black
|
private var plainBackgroundColor: UIColor = .black
|
||||||
private var pinnedBackgroundColor: UIColor = .black
|
private var pinnedBackgroundColor: UIColor = .black
|
||||||
private var currentProgress: CGFloat = 0.0
|
private var currentProgress: CGFloat = 0.0
|
||||||
@@ -1055,7 +1184,7 @@ private final class ChatListHeaderBlurView: UIView {
|
|||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
isUserInteractionEnabled = false
|
isUserInteractionEnabled = false
|
||||||
|
|
||||||
// Backdrop blur layer — very subtle (radius 1.0), no colorMatrix
|
// Backdrop blur layer (Telegram: VariableBlurView maxRadius 1.0)
|
||||||
if let backdrop = BackdropLayerHelper.createBackdropLayer() {
|
if let backdrop = BackdropLayerHelper.createBackdropLayer() {
|
||||||
backdrop.delegate = BackdropLayerDelegate.shared
|
backdrop.delegate = BackdropLayerDelegate.shared
|
||||||
BackdropLayerHelper.setScale(backdrop, scale: 0.5)
|
BackdropLayerHelper.setScale(backdrop, scale: 0.5)
|
||||||
@@ -1067,14 +1196,11 @@ private final class ChatListHeaderBlurView: UIView {
|
|||||||
self.backdropLayer = backdrop
|
self.backdropLayer = backdrop
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tint view with gradient mask (for pinned section color)
|
// Tint overlay with gradient mask (Telegram: contentView at alpha 0.85)
|
||||||
tintView.mask = tintMaskView
|
tintView.mask = tintMaskView
|
||||||
tintView.alpha = 0.85
|
tintView.alpha = 0.85
|
||||||
addSubview(tintView)
|
addSubview(tintView)
|
||||||
|
|
||||||
// Gradient fade mask on the whole view
|
|
||||||
layer.mask = fadeMaskLayer
|
|
||||||
|
|
||||||
applyAdaptiveColors()
|
applyAdaptiveColors()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1093,7 +1219,6 @@ private final class ChatListHeaderBlurView: UIView {
|
|||||||
backdropLayer?.frame = bounds
|
backdropLayer?.frame = bounds
|
||||||
tintView.frame = bounds
|
tintView.frame = bounds
|
||||||
tintMaskView.frame = bounds
|
tintMaskView.frame = bounds
|
||||||
updateFadeMask()
|
|
||||||
updateTintMask()
|
updateTintMask()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1104,6 +1229,7 @@ private final class ChatListHeaderBlurView: UIView {
|
|||||||
updateChromeOpacity()
|
updateChromeOpacity()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Telegram: plainBackgroundColor.mixedWith(pinnedItemBackgroundColor, alpha: pinnedFraction)
|
||||||
private func updateEdgeEffectColor() {
|
private func updateEdgeEffectColor() {
|
||||||
let effectivePinnedFraction = isSearchCurrentlyActive ? 0.0 : currentPinnedFraction
|
let effectivePinnedFraction = isSearchCurrentlyActive ? 0.0 : currentPinnedFraction
|
||||||
let resolved = plainBackgroundColor.mixedWith(pinnedBackgroundColor, alpha: effectivePinnedFraction)
|
let resolved = plainBackgroundColor.mixedWith(pinnedBackgroundColor, alpha: effectivePinnedFraction)
|
||||||
@@ -1112,8 +1238,7 @@ private final class ChatListHeaderBlurView: UIView {
|
|||||||
|
|
||||||
private func updateChromeOpacity() {
|
private func updateChromeOpacity() {
|
||||||
let clamped = max(0.0, min(1.0, currentProgress))
|
let clamped = max(0.0, min(1.0, currentProgress))
|
||||||
// Backdrop blur is always present — its visibility depends on content behind.
|
// Telegram: content alpha is always 0.85; we modulate by scroll progress
|
||||||
// Tint overlay fades in with scroll progress.
|
|
||||||
tintView.alpha = 0.85 * clamped
|
tintView.alpha = 0.85 * clamped
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1123,17 +1248,6 @@ private final class ChatListHeaderBlurView: UIView {
|
|||||||
tintMaskView.image = VariableBlurEdgeView.generateEdgeGradient(baseHeight: edgeSize)
|
tintMaskView.image = VariableBlurEdgeView.generateEdgeGradient(baseHeight: edgeSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateFadeMask() {
|
|
||||||
let height = max(1, bounds.height)
|
|
||||||
let fadeHeight = min(54.0, height)
|
|
||||||
let fadeStart = max(0.0, (height - fadeHeight) / height)
|
|
||||||
fadeMaskLayer.frame = bounds
|
|
||||||
fadeMaskLayer.startPoint = CGPoint(x: 0.5, y: 0)
|
|
||||||
fadeMaskLayer.endPoint = CGPoint(x: 0.5, y: 1)
|
|
||||||
fadeMaskLayer.colors = [UIColor.black.cgColor, UIColor.black.cgColor, UIColor.clear.cgColor]
|
|
||||||
fadeMaskLayer.locations = [0, NSNumber(value: Float(fadeStart)), 1]
|
|
||||||
}
|
|
||||||
|
|
||||||
func setProgress(_ progress: CGFloat, pinnedFraction: CGFloat, isSearchActive: Bool) {
|
func setProgress(_ progress: CGFloat, pinnedFraction: CGFloat, isSearchActive: Bool) {
|
||||||
currentProgress = max(0.0, min(1.0, progress))
|
currentProgress = max(0.0, min(1.0, progress))
|
||||||
currentPinnedFraction = max(0.0, min(1.0, pinnedFraction))
|
currentPinnedFraction = max(0.0, min(1.0, pinnedFraction))
|
||||||
|
|||||||
Reference in New Issue
Block a user