Files
mobile-ios/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift

1520 lines
57 KiB
Swift

import Combine
import Observation
import SwiftUI
import UIKit
struct ChatListUIKitView: View {
@Binding var isSearchActive: Bool
@Binding var isDetailPresented: Bool
var body: some View {
ChatListUIKitContainer(
isSearchActive: $isSearchActive,
isDetailPresented: $isDetailPresented
)
}
}
private struct ChatListUIKitContainer: UIViewControllerRepresentable {
@Binding var isSearchActive: Bool
@Binding var isDetailPresented: Bool
func makeCoordinator() -> ChatListUIKitCoordinator {
ChatListUIKitCoordinator(
isSearchActive: $isSearchActive,
isDetailPresented: $isDetailPresented
)
}
func makeUIViewController(context: Context) -> UINavigationController {
let root = ChatListRootViewController()
root.onSearchActiveChanged = { [weak coordinator = context.coordinator] active in
coordinator?.setSearchActive(active)
}
root.onDetailPresentedChanged = { [weak coordinator = context.coordinator] presented in
coordinator?.setDetailPresented(presented)
}
let nav = UINavigationController(rootViewController: root)
nav.view.backgroundColor = UIColor(RosettaColors.Adaptive.background)
nav.navigationBar.tintColor = UIColor(RosettaColors.Adaptive.text)
nav.navigationBar.prefersLargeTitles = false
return nav
}
func updateUIViewController(_ navigationController: UINavigationController, context: Context) {
context.coordinator.isSearchActive = $isSearchActive
context.coordinator.isDetailPresented = $isDetailPresented
}
}
private final class ChatListUIKitCoordinator {
var isSearchActive: Binding<Bool>
var isDetailPresented: Binding<Bool>
init(isSearchActive: Binding<Bool>, isDetailPresented: Binding<Bool>) {
self.isSearchActive = isSearchActive
self.isDetailPresented = isDetailPresented
}
func setSearchActive(_ active: Bool) {
DispatchQueue.main.async {
if self.isSearchActive.wrappedValue != active {
self.isSearchActive.wrappedValue = active
}
}
}
func setDetailPresented(_ presented: Bool) {
DispatchQueue.main.async {
if self.isDetailPresented.wrappedValue != presented {
self.isDetailPresented.wrappedValue = presented
}
}
}
}
@MainActor
final class ChatListRootViewController: UIViewController, UINavigationControllerDelegate {
var onSearchActiveChanged: ((Bool) -> Void)?
var onDetailPresentedChanged: ((Bool) -> Void)?
private let viewModel = ChatListViewModel()
private let listController = ChatListCollectionController()
private let searchHeaderView = ChatListSearchHeaderView()
private let toolbarTitleView = ChatListToolbarTitleView()
private let navigationBlurView = ChatListHeaderBlurView()
private var typingDialogs: [String: Set<String>] = [:]
private var currentSearchQuery = ""
private var searchResultUsersByKey: [String: SearchUser] = [:]
private let searchTopSpacing: CGFloat = 10
private let searchBottomSpacing: CGFloat = 5
private let searchHeaderHeight: CGFloat = 44
private var searchChromeHeight: CGFloat {
searchTopSpacing + searchHeaderHeight + searchBottomSpacing
}
private var searchHeaderTopConstraint: NSLayoutConstraint?
private var searchHeaderHeightConstraint: NSLayoutConstraint?
private var navigationBlurHeightConstraint: NSLayoutConstraint?
private var lastSearchExpansion: CGFloat = 1.0
private var lastNavigationBlurProgress: CGFloat = -1.0
private var lastNavigationBlurSearchActive = false
private var lastNavigationBlurPinnedFraction: CGFloat = -1.0
private var hasPinnedDialogs = false
private var pinnedHeaderFraction: CGFloat = 0.0
private var typingDialogsCancellable: AnyCancellable?
private var searchResultsCancellable: AnyCancellable?
private var observationTask: Task<Void, Never>?
private var openChatObserver: NSObjectProtocol?
private var didBecomeActiveObserver: NSObjectProtocol?
private lazy var editButtonControl: ChatListToolbarEditButton = {
let button = ChatListToolbarEditButton()
button.addTarget(self, action: #selector(editTapped), for: .touchUpInside)
return button
}()
private lazy var rightButtonsControl: ChatListToolbarDualActionButton = {
let button = ChatListToolbarDualActionButton()
button.onAddPressed = { [weak self] in
self?.addPressed()
}
button.onComposePressed = { [weak self] in
self?.composeTapped()
}
return button
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(RosettaColors.Adaptive.background)
setupNavigationChrome()
setupSearchHeader()
setupListController()
setupObservers()
startObservationLoop()
render()
consumePendingRouteIfFresh()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if navigationController?.delegate !== self {
navigationController?.delegate = self
}
// Hide nav bar on chat list; pushed VCs show it via willShow delegate
navigationController?.setNavigationBarHidden(true, animated: animated)
let blurProgress = searchHeaderView.isSearchActive ? 1.0 : (1.0 - lastSearchExpansion)
updateNavigationBarBlur(progress: blurProgress)
onDetailPresentedChanged?(navigationController?.viewControllers.count ?? 1 > 1)
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
layoutCustomHeader()
updateNavigationBlurHeight()
}
private func layoutCustomHeader() {
let statusBarHeight = view.safeAreaInsets.top
let centerY = statusBarHeight + headerBarHeight * 0.5
let sideInset: CGFloat = 16
// Edit button (left)
let editSize = editButtonControl.intrinsicContentSize
editButtonControl.frame = CGRect(
x: sideInset,
y: centerY - editSize.height * 0.5,
width: editSize.width,
height: editSize.height
)
// Right buttons (right)
let rightSize = rightButtonsControl.intrinsicContentSize
rightButtonsControl.frame = CGRect(
x: view.bounds.width - sideInset - rightSize.width,
y: centerY - rightSize.height * 0.5,
width: rightSize.width,
height: rightSize.height
)
// Title (centered)
let titleLeft = editButtonControl.frame.maxX + 8
let titleRight = rightButtonsControl.frame.minX - 8
let titleWidth = max(0, titleRight - titleLeft)
let titleSize = toolbarTitleView.intrinsicContentSize
let actualWidth = min(titleSize.width, titleWidth)
toolbarTitleView.frame = CGRect(
x: titleLeft + (titleWidth - actualWidth) * 0.5,
y: centerY - 15,
width: actualWidth,
height: 30
)
}
private func updateNavigationBlurHeight() {
let headerBottom = headerTotalHeight
let expandedSearchHeight = searchChromeHeight * lastSearchExpansion
navigationBlurHeightConstraint?.constant = max(0, headerBottom + expandedSearchHeight + 14.0)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
// Keep tab bar state in sync when leaving the screen while search is active.
if searchHeaderView.isSearchActive {
searchHeaderView.endSearch(animated: false, clearText: true)
}
}
deinit {
observationTask?.cancel()
if let openChatObserver {
NotificationCenter.default.removeObserver(openChatObserver)
}
if let didBecomeActiveObserver {
NotificationCenter.default.removeObserver(didBecomeActiveObserver)
}
}
/// Height of the custom header bar content (matches UINavigationBar standard)
private let headerBarHeight: CGFloat = 44
/// Computed total header height: status bar + header bar
private var headerTotalHeight: CGFloat {
view.safeAreaInsets.top + headerBarHeight
}
private func setupNavigationChrome() {
definesPresentationContext = true
// Blur background layer (below buttons)
navigationBlurView.translatesAutoresizingMaskIntoConstraints = false
navigationBlurView.isUserInteractionEnabled = false
navigationBlurView.layer.zPosition = 40
view.addSubview(navigationBlurView)
let blurHeight = navigationBlurView.heightAnchor.constraint(equalToConstant: 0)
navigationBlurHeightConstraint = blurHeight
NSLayoutConstraint.activate([
navigationBlurView.topAnchor.constraint(equalTo: view.topAnchor),
navigationBlurView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
navigationBlurView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
blurHeight,
])
// Header elements as direct subviews (z=55, above blur at z=40 and search at z=50)
for v: UIView in [editButtonControl, rightButtonsControl, toolbarTitleView] {
v.layer.zPosition = 55
view.addSubview(v)
}
toolbarTitleView.addTarget(self, action: #selector(handleTitleTapped), for: .touchUpInside)
updateNavigationBarBlur(progress: 0)
}
@objc private func handleTitleTapped() {
NotificationCenter.default.post(name: .chatListScrollToTop, object: nil)
}
private func setupSearchHeader() {
searchHeaderView.translatesAutoresizingMaskIntoConstraints = false
searchHeaderView.clipsToBounds = true
searchHeaderView.layer.zPosition = 50
view.addSubview(searchHeaderView)
// With nav bar hidden, safeArea.top = status bar only.
// Search bar goes below our custom 44pt header bar.
let top = searchHeaderView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: headerBarHeight + searchTopSpacing)
let height = searchHeaderView.heightAnchor.constraint(equalToConstant: searchHeaderHeight)
searchHeaderTopConstraint = top
searchHeaderHeightConstraint = height
NSLayoutConstraint.activate([
top,
searchHeaderView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16),
searchHeaderView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),
height,
])
searchHeaderView.onQueryChanged = { [weak self] query in
guard let self else { return }
self.currentSearchQuery = query
self.viewModel.setSearchQuery(query)
self.renderList()
}
searchHeaderView.onActiveChanged = { [weak self] active in
guard let self else { return }
self.applySearchExpansion(1.0, animated: true)
self.updateNavigationBarBlur(progress: active ? 1.0 : (1.0 - self.lastSearchExpansion))
self.onSearchActiveChanged?(active)
}
}
private func setupListController() {
addChild(listController)
view.insertSubview(listController.view, belowSubview: searchHeaderView)
listController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
listController.view.topAnchor.constraint(equalTo: view.topAnchor),
listController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
listController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
listController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
listController.didMove(toParent: self)
listController.setSearchHeaderExpansion(lastSearchExpansion)
listController.onSelectDialog = { [weak self] dialog in
guard let self else { return }
let selectedUser = self.searchResultUsersByKey[dialog.opponentKey]
let shouldOpenUserRoute = selectedUser != nil && !self.currentSearchQuery.isEmpty
let routeToOpen: ChatRoute
if let user = selectedUser, shouldOpenUserRoute {
self.viewModel.addToRecent(user)
routeToOpen = ChatRoute(user: user)
} else {
routeToOpen = ChatRoute(dialog: dialog)
}
if self.searchHeaderView.isSearchActive {
self.searchHeaderView.endSearch(animated: false, clearText: true)
// Let keyboard dismissal complete before starting the push animation.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
self?.openChat(route: routeToOpen)
}
return
}
self.openChat(route: routeToOpen)
}
listController.onDeleteDialog = { [weak self] dialog in
self?.viewModel.deleteDialog(dialog)
}
listController.onTogglePin = { [weak self] dialog in
guard let self else { return }
self.viewModel.togglePin(dialog)
self.renderList()
let progress = self.searchHeaderView.isSearchActive ? 1.0 : (1.0 - self.lastSearchExpansion)
self.updateNavigationBarBlur(progress: progress, force: true)
}
listController.onToggleMute = { [weak self] dialog in
self?.viewModel.toggleMute(dialog)
}
listController.onPinnedStateChange = { [weak self] hasPinned in
guard let self else { return }
self.hasPinnedDialogs = hasPinned
if !hasPinned {
self.pinnedHeaderFraction = 0.0
}
let progress = self.searchHeaderView.isSearchActive ? 1.0 : (1.0 - self.lastSearchExpansion)
self.updateNavigationBarBlur(progress: progress, force: true)
}
listController.onPinnedHeaderFractionChange = { [weak self] fraction in
guard let self else { return }
self.pinnedHeaderFraction = self.hasPinnedDialogs ? fraction : 0.0
let progress = self.searchHeaderView.isSearchActive ? 1.0 : (1.0 - self.lastSearchExpansion)
self.updateNavigationBarBlur(progress: progress)
}
listController.onMarkAsRead = { [weak self] dialog in
self?.viewModel.markAsRead(dialog)
}
listController.onShowRequests = { [weak self] in
self?.openRequests()
}
listController.onScrollOffsetChange = { [weak self] expansion in
self?.applySearchExpansion(expansion, animated: false)
}
}
private func setupObservers() {
typingDialogsCancellable = MessageRepository.shared.$typingDialogs
.receive(on: DispatchQueue.main)
.sink { [weak self] typing in
guard let self else { return }
self.typingDialogs = typing
self.renderList()
}
searchResultsCancellable = viewModel.$serverSearchResults
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.renderList()
}
openChatObserver = NotificationCenter.default.addObserver(
forName: .openChatFromNotification,
object: nil,
queue: .main
) { [weak self] notification in
guard let route = notification.object as? ChatRoute else { return }
AppDelegate.pendingChatRoute = nil
AppDelegate.pendingChatRouteTimestamp = nil
Task { @MainActor [weak self] in
self?.openChat(route: route)
}
}
didBecomeActiveObserver = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.consumePendingRouteIfFresh()
}
}
}
private func startObservationLoop() {
observationTask?.cancel()
observationTask = Task { @MainActor [weak self] in
self?.observeState()
}
}
private func observeState() {
withObservationTracking {
_ = DialogRepository.shared.dialogs
_ = SessionManager.shared.syncBatchInProgress
_ = ProtocolManager.shared.connectionState
_ = AvatarRepository.shared.avatarVersion
} onChange: { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
self.render()
self.observeState()
}
}
}
private func render() {
updateNavigationTitle()
renderList()
}
private func applySearchExpansion(_ expansion: CGFloat, animated: Bool) {
let clamped = max(0.0, min(1.0, expansion))
if searchHeaderView.isSearchActive && clamped < 0.999 {
return
}
guard abs(clamped - lastSearchExpansion) > 0.003 else { return }
lastSearchExpansion = clamped
// Structural: top stays fixed, height collapses (Telegram: 60*progress, we use 44)
searchHeaderTopConstraint?.constant = headerBarHeight + searchTopSpacing
searchHeaderHeightConstraint?.constant = searchHeaderHeight * clamped
listController.setSearchHeaderExpansion(clamped)
// 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)
let updates = { self.view.layoutIfNeeded() }
if animated {
UIView.animate(
withDuration: 0.16,
delay: 0,
options: [.curveEaseInOut, .beginFromCurrentState],
animations: updates
)
} else {
updates()
}
}
private func updateNavigationBarBlur(progress: CGFloat, force: Bool = false) {
let clamped = max(0.0, min(1.0, progress))
let isSearchActive = searchHeaderView.isSearchActive
let effectivePinnedFraction = isSearchActive ? 0.0 : pinnedHeaderFraction
let hasChanged = abs(clamped - lastNavigationBlurProgress) > 0.01
|| isSearchActive != lastNavigationBlurSearchActive
|| abs(effectivePinnedFraction - lastNavigationBlurPinnedFraction) > 0.01
guard force || hasChanged else { return }
lastNavigationBlurProgress = clamped
lastNavigationBlurSearchActive = isSearchActive
lastNavigationBlurPinnedFraction = effectivePinnedFraction
navigationBlurView.setProgress(
clamped,
pinnedFraction: effectivePinnedFraction,
isSearchActive: isSearchActive
)
}
private func updateNavigationTitle() {
let state = ProtocolManager.shared.connectionState
let isSyncing = SessionManager.shared.syncBatchInProgress
let publicKey = AccountManager.shared.currentAccount?.publicKey ?? SessionManager.shared.currentPublicKey
let displayName = SessionManager.shared.displayName
let initials = RosettaColors.initials(name: displayName, publicKey: publicKey)
let avatarIndex = RosettaColors.avatarColorIndex(for: displayName, publicKey: publicKey)
let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: publicKey)
if state == .authenticated && isSyncing {
toolbarTitleView.configure(
title: "Updating...",
mode: .loading,
initials: initials,
avatarIndex: avatarIndex,
avatarImage: avatarImage
)
} else if state == .authenticated {
toolbarTitleView.configure(
title: "Chats",
mode: .avatar,
initials: initials,
avatarIndex: avatarIndex,
avatarImage: avatarImage
)
} else {
toolbarTitleView.configure(
title: "Connecting...",
mode: .loading,
initials: initials,
avatarIndex: avatarIndex,
avatarImage: avatarImage
)
}
}
private func renderList() {
let dialogs = dialogsForCurrentSearchState()
let pinned = dialogs.filter(\.isPinned)
let unpinned = dialogs.filter { !$0.isPinned }
let requestsCount = currentSearchQuery.isEmpty ? viewModel.requestsCount : 0
hasPinnedDialogs = !pinned.isEmpty
if !hasPinnedDialogs {
pinnedHeaderFraction = 0.0
}
listController.updateDialogs(
pinned: pinned,
unpinned: unpinned,
requestsCount: requestsCount,
typingDialogs: typingDialogs,
isSyncing: SessionManager.shared.syncBatchInProgress
)
}
private func dialogsForCurrentSearchState() -> [Dialog] {
searchResultUsersByKey = [:]
let all = viewModel.allModeDialogs
let query = currentSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return all }
if !viewModel.serverSearchResults.isEmpty {
let account = SessionManager.shared.currentPublicKey
let dialogs = viewModel.serverSearchResults.map { user in
searchResultUsersByKey[user.publicKey] = user
return Dialog(
id: user.publicKey,
account: account,
opponentKey: user.publicKey,
opponentTitle: user.title,
opponentUsername: user.username,
lastMessage: "",
lastMessageTimestamp: 0,
unreadCount: 0,
isOnline: user.online == 0,
lastSeen: 0,
verified: user.verified,
iHaveSent: true,
isPinned: false,
isMuted: false,
lastMessageFromMe: false,
lastMessageDelivered: .delivered,
lastMessageRead: false
)
}
return dialogs
}
let normalized = query.lowercased()
return all.filter { dialog in
dialog.opponentTitle.lowercased().contains(normalized)
|| dialog.opponentUsername.lowercased().contains(normalized)
|| dialog.opponentKey.lowercased().contains(normalized)
}
}
private func consumePendingRouteIfFresh() {
guard let route = AppDelegate.consumeFreshPendingRoute() else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.openChat(route: route)
}
}
private func openChat(route: ChatRoute) {
let detail = ChatDetailView(
route: route,
onPresentedChange: { [weak self] presented in
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() {
let vc = RequestChatsUIKitShellController(viewModel: viewModel)
vc.onOpenRoute = { [weak self] route in
self?.openChat(route: route)
}
navigationController?.pushViewController(vc, animated: true)
}
@objc private func editTapped() {
// UIKit shell parity: keep action reserved for future editing mode.
}
@objc private func addPressed() {
composeTapped()
}
@objc private func composeTapped() {
let sheet = UIAlertController(title: "New", message: nil, preferredStyle: .actionSheet)
sheet.addAction(UIAlertAction(title: "New Group", style: .default, handler: { [weak self] _ in
self?.presentGroupSetup()
}))
sheet.addAction(UIAlertAction(title: "Join Group", style: .default, handler: { [weak self] _ in
self?.presentGroupJoin()
}))
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
if let popover = sheet.popoverPresentationController {
popover.sourceView = rightButtonsControl
popover.sourceRect = rightButtonsControl.bounds
popover.permittedArrowDirections = .up
}
present(sheet, animated: true)
}
private func presentGroupSetup() {
let root = NavigationStack {
GroupSetupView { [weak self] route in
guard let self else { return }
self.dismiss(animated: true) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
self.openChat(route: route)
}
}
}
}
let host = UIHostingController(rootView: root)
host.view.backgroundColor = UIColor(RosettaColors.Adaptive.background)
let nav = UINavigationController(rootViewController: host)
nav.modalPresentationStyle = .pageSheet
if let sheet = nav.sheetPresentationController {
sheet.detents = [.large()]
}
present(nav, animated: true)
}
private func presentGroupJoin() {
let root = NavigationStack {
GroupJoinView { [weak self] route in
guard let self else { return }
self.dismiss(animated: true) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
self.openChat(route: route)
}
}
}
}
let host = UIHostingController(rootView: root)
host.view.backgroundColor = UIColor(RosettaColors.Adaptive.background)
let nav = UINavigationController(rootViewController: host)
nav.modalPresentationStyle = .pageSheet
if let sheet = nav.sheetPresentationController {
sheet.detents = [.large()]
}
present(nav, animated: true)
}
// MARK: - UINavigationControllerDelegate
func navigationController(
_ navigationController: UINavigationController,
willShow viewController: UIViewController,
animated: Bool
) {
// Show standard nav bar for pushed screens, hide on chat list
let isChatList = viewController === self
navigationController.setNavigationBarHidden(isChatList, animated: animated)
}
func navigationController(
_ navigationController: UINavigationController,
didShow viewController: UIViewController,
animated: Bool
) {
let isPresented = navigationController.viewControllers.count > 1
onDetailPresentedChanged?(isPresented)
}
}
@MainActor
final class RequestChatsUIKitShellController: UIViewController {
var onOpenRoute: ((ChatRoute) -> Void)?
private let viewModel: ChatListViewModel
private let requestsController = RequestChatsController()
private var observationTask: Task<Void, Never>?
init(viewModel: ChatListViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(RosettaColors.Adaptive.background)
title = "Request Chats"
addChild(requestsController)
view.addSubview(requestsController.view)
requestsController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
requestsController.view.topAnchor.constraint(equalTo: view.topAnchor),
requestsController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
requestsController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
requestsController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
requestsController.didMove(toParent: self)
requestsController.onSelectDialog = { [weak self] dialog in
self?.onOpenRoute?(ChatRoute(dialog: dialog))
}
requestsController.onDeleteDialog = { [weak self] dialog in
self?.viewModel.deleteDialog(dialog)
}
startObservationLoop()
render()
}
deinit {
observationTask?.cancel()
}
private func startObservationLoop() {
observationTask?.cancel()
observationTask = Task { @MainActor [weak self] in
self?.observeState()
}
}
private func observeState() {
withObservationTracking {
_ = DialogRepository.shared.dialogs
_ = SessionManager.shared.syncBatchInProgress
} onChange: { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
self.render()
self.observeState()
}
}
}
private func render() {
requestsController.updateDialogs(
viewModel.requestsModeDialogs,
isSyncing: SessionManager.shared.syncBatchInProgress
)
}
}
private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
var onQueryChanged: ((String) -> Void)?
var onActiveChanged: ((Bool) -> Void)?
private(set) var isSearchActive = false
private let capsuleView = UIView()
private let placeholderStack = UIStackView()
private let placeholderIcon = UIImageView(image: UIImage(systemName: "magnifyingglass"))
private let placeholderLabel = UILabel()
private let activeStack = UIStackView()
private let activeIcon = UIImageView(image: UIImage(systemName: "magnifyingglass"))
private let textField = UITextField()
private let inlineClearButton = UIButton(type: .system)
private let cancelButton = UIButton(type: .system)
private var cancelWidthConstraint: NSLayoutConstraint!
private var suppressQueryCallback = false
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
applyColors()
updateVisualState(animated: false)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
applyColors()
}
func endSearch(animated: Bool, clearText: Bool) {
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
capsuleView.clipsToBounds = true
addSubview(capsuleView)
placeholderStack.translatesAutoresizingMaskIntoConstraints = false
placeholderStack.axis = .horizontal
placeholderStack.alignment = .center
placeholderStack.spacing = 4
placeholderIcon.contentMode = .scaleAspectFit
placeholderIcon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)
placeholderLabel.text = "Search"
placeholderLabel.font = .systemFont(ofSize: 17)
placeholderStack.addArrangedSubview(placeholderIcon)
placeholderStack.addArrangedSubview(placeholderLabel)
capsuleView.addSubview(placeholderStack)
activeStack.translatesAutoresizingMaskIntoConstraints = false
activeStack.axis = .horizontal
activeStack.alignment = .center
activeStack.spacing = 2
activeIcon.contentMode = .scaleAspectFit
activeIcon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)
textField.translatesAutoresizingMaskIntoConstraints = false
textField.placeholder = "Search"
textField.font = .systemFont(ofSize: 17)
textField.autocapitalizationType = .none
textField.autocorrectionType = .no
textField.returnKeyType = .search
textField.clearButtonMode = .never
textField.delegate = self
textField.addTarget(self, action: #selector(handleTextChanged), for: .editingChanged)
inlineClearButton.translatesAutoresizingMaskIntoConstraints = false
inlineClearButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
inlineClearButton.addTarget(self, action: #selector(handleInlineClearTapped), for: .touchUpInside)
activeStack.addArrangedSubview(activeIcon)
activeStack.addArrangedSubview(textField)
activeStack.addArrangedSubview(inlineClearButton)
capsuleView.addSubview(activeStack)
cancelButton.translatesAutoresizingMaskIntoConstraints = false
cancelButton.setTitle("Cancel", for: .normal)
cancelButton.titleLabel?.font = .systemFont(ofSize: 17)
cancelButton.contentHorizontalAlignment = .right
cancelButton.addTarget(self, action: #selector(handleCancelTapped), for: .touchUpInside)
addSubview(cancelButton)
let tap = UITapGestureRecognizer(target: self, action: #selector(handleCapsuleTapped))
capsuleView.addGestureRecognizer(tap)
cancelWidthConstraint = cancelButton.widthAnchor.constraint(equalToConstant: 0)
NSLayoutConstraint.activate([
capsuleView.leadingAnchor.constraint(equalTo: leadingAnchor),
capsuleView.topAnchor.constraint(equalTo: topAnchor),
capsuleView.bottomAnchor.constraint(equalTo: bottomAnchor),
capsuleView.trailingAnchor.constraint(equalTo: cancelButton.leadingAnchor),
cancelButton.trailingAnchor.constraint(equalTo: trailingAnchor),
cancelButton.centerYAnchor.constraint(equalTo: centerYAnchor),
cancelWidthConstraint,
placeholderStack.centerXAnchor.constraint(equalTo: capsuleView.centerXAnchor),
placeholderStack.centerYAnchor.constraint(equalTo: capsuleView.centerYAnchor),
activeStack.leadingAnchor.constraint(equalTo: capsuleView.leadingAnchor, constant: 12),
activeStack.trailingAnchor.constraint(equalTo: capsuleView.trailingAnchor, constant: -10),
activeStack.topAnchor.constraint(equalTo: capsuleView.topAnchor),
activeStack.bottomAnchor.constraint(equalTo: capsuleView.bottomAnchor),
inlineClearButton.widthAnchor.constraint(equalToConstant: 24),
inlineClearButton.heightAnchor.constraint(equalToConstant: 24),
])
}
private func applyColors() {
// 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)
}
private func updateVisualState(animated: Bool) {
let updates = {
self.placeholderStack.alpha = self.isSearchActive ? 0 : 1
self.activeStack.alpha = self.isSearchActive ? 1 : 0
self.cancelButton.alpha = self.isSearchActive ? 1 : 0
self.cancelWidthConstraint.constant = self.isSearchActive ? 64 : 0
self.layoutIfNeeded()
}
if animated {
UIView.animate(withDuration: 0.16, delay: 0, options: [.curveEaseInOut, .beginFromCurrentState], animations: updates)
} else {
updates()
}
}
private func setSearchActive(_ active: Bool, animated: Bool, clearText: Bool = false) {
let oldValue = isSearchActive
isSearchActive = active
if !active {
textField.resignFirstResponder()
if clearText {
setQueryText("")
}
} else {
DispatchQueue.main.async { [weak self] in
self?.textField.becomeFirstResponder()
}
}
if oldValue != active {
onActiveChanged?(active)
}
updateClearButtonVisibility()
updateVisualState(animated: animated)
}
private func setQueryText(_ text: String) {
let old = textField.text ?? ""
guard old != text else { return }
suppressQueryCallback = true
textField.text = text
suppressQueryCallback = false
updateClearButtonVisibility()
onQueryChanged?(text)
}
private func updateClearButtonVisibility() {
inlineClearButton.alpha = (textField.text?.isEmpty == false && isSearchActive) ? 1 : 0
inlineClearButton.isUserInteractionEnabled = inlineClearButton.alpha > 0
}
@objc private func handleCapsuleTapped() {
guard !isSearchActive else { return }
setSearchActive(true, animated: true)
}
@objc private func handleTextChanged() {
updateClearButtonVisibility()
guard !suppressQueryCallback else { return }
onQueryChanged?(textField.text ?? "")
}
@objc private func handleInlineClearTapped() {
setQueryText("")
}
@objc private func handleCancelTapped() {
endSearch(animated: true, clearText: true)
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
}
private final class ChatListHeaderBlurView: UIView {
// 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
private var currentProgress: CGFloat = 0.0
private var currentPinnedFraction: CGFloat = 0.0
private var isSearchCurrentlyActive = false
override init(frame: CGRect) {
super.init(frame: frame)
isUserInteractionEnabled = 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
}
// 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()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
guard traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle else { return }
applyAdaptiveColors()
}
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)
updateEdgeEffectColor()
updateChromeOpacity()
}
private func updateEdgeEffectColor() {
let effectivePinnedFraction = isSearchCurrentlyActive ? 0.0 : currentPinnedFraction
let resolved = plainBackgroundColor.mixedWith(pinnedBackgroundColor, alpha: effectivePinnedFraction)
tintView.backgroundColor = resolved
}
private func updateChromeOpacity() {
let clamped = max(0.0, min(1.0, currentProgress))
// 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 updateTintMask() {
let height = max(1, bounds.height)
let edgeSize = min(54.0, height)
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) {
currentProgress = max(0.0, min(1.0, progress))
currentPinnedFraction = max(0.0, min(1.0, pinnedFraction))
isSearchCurrentlyActive = isSearchActive
updateEdgeEffectColor()
updateChromeOpacity()
}
}
private final class ChatListToolbarGlassCapsuleView: UIView {
private let glassView = TelegramGlassUIView(frame: .zero)
override init(frame: CGRect) {
super.init(frame: frame)
isUserInteractionEnabled = false
clipsToBounds = false
addSubview(glassView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
glassView.frame = bounds
glassView.fixedCornerRadius = bounds.height * 0.5
glassView.updateGlass()
}
}
private final class ChatListToolbarEditButton: UIControl {
private let backgroundView = ChatListToolbarGlassCapsuleView()
private let titleLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
isAccessibilityElement = true
accessibilityLabel = "Edit"
accessibilityTraits = .button
addSubview(backgroundView)
backgroundView.translatesAutoresizingMaskIntoConstraints = false
titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.text = "Edit"
titleLabel.font = .systemFont(ofSize: 17, weight: .medium)
titleLabel.textColor = UIColor(RosettaColors.Adaptive.text)
titleLabel.textAlignment = .center
addSubview(titleLabel)
NSLayoutConstraint.activate([
backgroundView.topAnchor.constraint(equalTo: topAnchor),
backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor),
backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor),
backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor),
titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
heightAnchor.constraint(equalToConstant: 44),
])
self.frame = CGRect(origin: .zero, size: intrinsicContentSize)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var intrinsicContentSize: CGSize {
let textWidth = (titleLabel.text as NSString?)?.size(withAttributes: [.font: titleLabel.font!]).width ?? 0
let width = max(44.0, ceil(textWidth) + 24.0)
return CGSize(width: width, height: 44.0)
}
override var isHighlighted: Bool {
didSet {
if isHighlighted {
alpha = 0.6
} else {
UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut) {
self.alpha = 1.0
}
}
}
}
}
private final class ChatListToolbarDualActionButton: UIView {
var onAddPressed: (() -> Void)?
var onComposePressed: (() -> Void)?
private let backgroundView = ChatListToolbarGlassCapsuleView()
private let addButton = UIButton(type: .system)
private let composeButton = UIButton(type: .system)
private let dividerView = UIView()
override init(frame: CGRect) {
super.init(frame: frame)
isAccessibilityElement = false
backgroundView.translatesAutoresizingMaskIntoConstraints = false
addSubview(backgroundView)
addButton.translatesAutoresizingMaskIntoConstraints = false
composeButton.translatesAutoresizingMaskIntoConstraints = false
dividerView.translatesAutoresizingMaskIntoConstraints = false
addButton.tintColor = UIColor(RosettaColors.Adaptive.text)
composeButton.tintColor = UIColor(RosettaColors.Adaptive.text)
addButton.accessibilityLabel = "Add"
composeButton.accessibilityLabel = "Compose"
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)
?? UIImage(systemName: "square.and.pencil", withConfiguration: iconConfig)
addButton.setImage(addIcon, for: .normal)
composeButton.setImage(composeIcon, for: .normal)
addButton.addTarget(self, action: #selector(handleAddTapped), for: .touchUpInside)
composeButton.addTarget(self, action: #selector(handleComposeTapped), for: .touchUpInside)
addButton.addTarget(self, action: #selector(handleTouchDown(_:)), for: .touchDown)
composeButton.addTarget(self, action: #selector(handleTouchDown(_:)), for: .touchDown)
addButton.addTarget(self, action: #selector(handleTouchUp(_:)), for: [.touchUpInside, .touchCancel, .touchDragExit])
composeButton.addTarget(self, action: #selector(handleTouchUp(_:)), for: [.touchUpInside, .touchCancel, .touchDragExit])
dividerView.backgroundColor = UIColor.white.withAlphaComponent(0.16)
addSubview(addButton)
addSubview(composeButton)
addSubview(dividerView)
NSLayoutConstraint.activate([
backgroundView.topAnchor.constraint(equalTo: topAnchor),
backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor),
backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor),
backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor),
addButton.leadingAnchor.constraint(equalTo: leadingAnchor),
addButton.topAnchor.constraint(equalTo: topAnchor),
addButton.bottomAnchor.constraint(equalTo: bottomAnchor),
addButton.widthAnchor.constraint(equalToConstant: 44),
composeButton.trailingAnchor.constraint(equalTo: trailingAnchor),
composeButton.topAnchor.constraint(equalTo: topAnchor),
composeButton.bottomAnchor.constraint(equalTo: bottomAnchor),
composeButton.widthAnchor.constraint(equalToConstant: 44),
dividerView.centerXAnchor.constraint(equalTo: centerXAnchor),
dividerView.centerYAnchor.constraint(equalTo: centerYAnchor),
dividerView.widthAnchor.constraint(equalToConstant: 1.0 / UIScreen.main.scale),
dividerView.heightAnchor.constraint(equalToConstant: 20),
heightAnchor.constraint(equalToConstant: 44),
widthAnchor.constraint(equalToConstant: 88),
])
self.frame = CGRect(origin: .zero, size: intrinsicContentSize)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var intrinsicContentSize: CGSize {
CGSize(width: 88, height: 44)
}
@objc private func handleAddTapped() {
onAddPressed?()
}
@objc private func handleComposeTapped() {
onComposePressed?()
}
@objc private func handleTouchDown(_ sender: UIButton) {
sender.alpha = 0.6
}
@objc private func handleTouchUp(_ sender: UIButton) {
UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut) {
sender.alpha = 1.0
}
}
}
private final class ChatListToolbarTitleView: UIControl {
enum Mode {
case loading
case avatar
}
private let stackView = UIStackView()
private let titleLabel = UILabel()
private let avatarContainer = UIView()
private let avatarImageView = UIImageView()
private let avatarInitialsLabel = UILabel()
private let spinner = ChatListToolbarArcSpinnerView()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var intrinsicContentSize: CGSize {
let fitting = stackView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
return CGSize(width: fitting.width, height: 30)
}
func configure(title: String, mode: Mode, initials: String, avatarIndex: Int, avatarImage: UIImage?) {
titleLabel.text = title
avatarInitialsLabel.text = initials
let tintColor = RosettaColors.avatarColor(for: avatarIndex)
avatarContainer.backgroundColor = tintColor
if let avatarImage {
avatarImageView.image = avatarImage
avatarImageView.isHidden = false
avatarInitialsLabel.isHidden = true
} else {
avatarImageView.image = nil
avatarImageView.isHidden = true
avatarInitialsLabel.isHidden = false
}
switch mode {
case .loading:
spinner.isHidden = false
spinner.startAnimating()
avatarContainer.isHidden = true
case .avatar:
spinner.stopAnimating()
spinner.isHidden = true
avatarContainer.isHidden = false
}
invalidateIntrinsicContentSize()
}
private func setupUI() {
isAccessibilityElement = true
accessibilityTraits = .button
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .horizontal
stackView.alignment = .center
stackView.spacing = 6
addSubview(stackView)
spinner.translatesAutoresizingMaskIntoConstraints = false
spinner.setColor(UIColor(RosettaColors.primaryBlue))
avatarContainer.translatesAutoresizingMaskIntoConstraints = false
avatarContainer.clipsToBounds = true
avatarContainer.layer.cornerRadius = 14
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
avatarImageView.contentMode = .scaleAspectFill
avatarImageView.clipsToBounds = true
avatarInitialsLabel.translatesAutoresizingMaskIntoConstraints = false
avatarInitialsLabel.font = .systemFont(ofSize: 12, weight: .semibold)
avatarInitialsLabel.textColor = .white
avatarInitialsLabel.textAlignment = .center
avatarContainer.addSubview(avatarImageView)
avatarContainer.addSubview(avatarInitialsLabel)
titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
titleLabel.textColor = .white
titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
stackView.addArrangedSubview(spinner)
stackView.addArrangedSubview(avatarContainer)
stackView.addArrangedSubview(titleLabel)
NSLayoutConstraint.activate([
stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
stackView.topAnchor.constraint(equalTo: topAnchor),
stackView.bottomAnchor.constraint(equalTo: bottomAnchor),
spinner.widthAnchor.constraint(equalToConstant: 20),
spinner.heightAnchor.constraint(equalToConstant: 20),
avatarContainer.widthAnchor.constraint(equalToConstant: 28),
avatarContainer.heightAnchor.constraint(equalToConstant: 28),
avatarImageView.leadingAnchor.constraint(equalTo: avatarContainer.leadingAnchor),
avatarImageView.trailingAnchor.constraint(equalTo: avatarContainer.trailingAnchor),
avatarImageView.topAnchor.constraint(equalTo: avatarContainer.topAnchor),
avatarImageView.bottomAnchor.constraint(equalTo: avatarContainer.bottomAnchor),
avatarInitialsLabel.leadingAnchor.constraint(equalTo: avatarContainer.leadingAnchor),
avatarInitialsLabel.trailingAnchor.constraint(equalTo: avatarContainer.trailingAnchor),
avatarInitialsLabel.topAnchor.constraint(equalTo: avatarContainer.topAnchor),
avatarInitialsLabel.bottomAnchor.constraint(equalTo: avatarContainer.bottomAnchor),
])
}
}
private final class ChatListToolbarArcSpinnerView: UIView {
private let arcLayer = CAShapeLayer()
private let animationKey = "chatlist.toolbar.arcSpinner.rotation"
override init(frame: CGRect) {
super.init(frame: frame)
isUserInteractionEnabled = false
backgroundColor = .clear
arcLayer.fillColor = UIColor.clear.cgColor
arcLayer.lineWidth = 2
arcLayer.lineCap = .round
layer.addSublayer(arcLayer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
stopAnimating()
}
override func willMove(toSuperview newSuperview: UIView?) {
super.willMove(toSuperview: newSuperview)
if newSuperview == nil {
stopAnimating()
}
}
override func layoutSubviews() {
super.layoutSubviews()
arcLayer.frame = bounds
let radius = max(0, min(bounds.width, bounds.height) * 0.5 - arcLayer.lineWidth)
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let start = -CGFloat.pi / 2 + (2 * CGFloat.pi * 0.05)
let end = -CGFloat.pi / 2 + (2 * CGFloat.pi * 0.78)
arcLayer.path = UIBezierPath(
arcCenter: center,
radius: radius,
startAngle: start,
endAngle: end,
clockwise: true
).cgPath
}
func setColor(_ color: UIColor) {
arcLayer.strokeColor = color.cgColor
}
func startAnimating() {
guard layer.animation(forKey: animationKey) == nil else { return }
let animation = CABasicAnimation(keyPath: "transform.rotation.z")
animation.fromValue = 0
animation.toValue = Double.pi * 2
animation.duration = 1.0
animation.repeatCount = .infinity
animation.timingFunction = CAMediaTimingFunction(name: .linear)
layer.add(animation, forKey: animationKey)
}
func stopAnimating() {
layer.removeAnimation(forKey: animationKey)
}
}