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

729 lines
28 KiB
Swift

import UIKit
import SwiftUI
// MARK: - ChatListCollectionController
/// UIViewController hosting a UICollectionView for the chat list.
/// Uses DiffableDataSource for smooth animated updates and manual-frame ChatListCell
/// for Telegram-level scroll performance.
///
/// Integrates into SwiftUI via `ChatListCollectionView` (UIViewControllerRepresentable).
final class ChatListCollectionController: UIViewController {
// MARK: - Sections
enum Section: Int, CaseIterable {
case requests
case pinned
case unpinned
}
// MARK: - Callbacks (to SwiftUI)
var onSelectDialog: ((Dialog) -> Void)?
var onDeleteDialog: ((Dialog) -> Void)?
var onTogglePin: ((Dialog) -> Void)?
var onToggleMute: ((Dialog) -> Void)?
var onPinnedStateChange: ((Bool) -> Void)?
var onPinnedHeaderFractionChange: ((CGFloat) -> Void)?
var onShowRequests: (() -> Void)?
var onScrollToTopRequested: (() -> Void)?
var onScrollOffsetChange: ((CGFloat) -> Void)?
var onMarkAsRead: ((Dialog) -> Void)?
// MARK: - Data
private(set) var pinnedDialogs: [Dialog] = []
private(set) var unpinnedDialogs: [Dialog] = []
private(set) var requestsCount: Int = 0
private(set) var typingDialogs: [String: Set<String>] = [:]
private(set) var isSyncing: Bool = false
private var lastReportedExpansion: CGFloat = 1.0
private var lastReportedPinnedHeaderFraction: CGFloat = -1.0
private let searchCollapseDistance: CGFloat = 54
/// Extra top offset for custom header bar (nav bar is hidden)
var customHeaderBarHeight: CGFloat = 44
private var searchHeaderExpansion: CGFloat = 1.0
private var hasInitializedTopOffset = false
private var isPinnedFractionReportScheduled = false
// MARK: - UI
private var collectionView: UICollectionView!
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 requestsCellRegistration: UICollectionView.CellRegistration<ChatListRequestsCell, Int>!
private let floatingTabBarTotalHeight: CGFloat = 72
private var chatListBottomInset: CGFloat {
floatingTabBarTotalHeight
}
// Dialog lookup by ID for cell configuration
private var dialogMap: [String: Dialog] = [:]
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(RosettaColors.Adaptive.background)
setupCollectionView()
setupCellRegistrations()
setupDataSource()
setupScrollToTop()
}
deinit {
NotificationCenter.default.removeObserver(self, name: .chatListScrollToTop, object: nil)
}
// MARK: - Collection View Setup
private func setupCollectionView() {
let layout = createLayout()
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.backgroundColor = UIColor(RosettaColors.Adaptive.background)
collectionView.delegate = self
collectionView.prefetchDataSource = self
collectionView.keyboardDismissMode = .onDrag
collectionView.showsVerticalScrollIndicator = false
collectionView.showsHorizontalScrollIndicator = false
collectionView.alwaysBounceVertical = true
collectionView.alwaysBounceHorizontal = false
collectionView.contentInsetAdjustmentBehavior = .never
applyInsets()
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([
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
applyInsets()
if !hasInitializedTopOffset {
collectionView.setContentOffset(
CGPoint(x: 0, y: -collectionView.contentInset.top),
animated: false
)
hasInitializedTopOffset = true
}
schedulePinnedHeaderFractionReport()
}
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
applyInsets()
}
private func applyInsets() {
guard collectionView != nil else { return }
let oldTopInset = collectionView.contentInset.top
let topInset = view.safeAreaInsets.top + customHeaderBarHeight + (searchCollapseDistance * searchHeaderExpansion)
let bottomInset = chatListBottomInset
collectionView.contentInset.top = topInset
collectionView.contentInset.bottom = bottomInset
collectionView.verticalScrollIndicatorInsets.top = topInset
collectionView.verticalScrollIndicatorInsets.bottom = bottomInset
guard hasInitializedTopOffset,
!collectionView.isDragging,
!collectionView.isDecelerating else { return }
let delta = topInset - oldTopInset
if abs(delta) > 0.1 {
CATransaction.begin()
CATransaction.setDisableActions(true)
collectionView.contentOffset.y -= delta
CATransaction.commit()
}
}
func setSearchHeaderExpansion(_ expansion: CGFloat) {
let clamped = max(0.0, min(1.0, expansion))
guard abs(searchHeaderExpansion - clamped) > 0.002 else { return }
CATransaction.begin()
CATransaction.setDisableActions(true)
searchHeaderExpansion = clamped
applyInsets()
CATransaction.commit()
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) {
if isPinnedFractionReportScheduled { return }
isPinnedFractionReportScheduled = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.isPinnedFractionReportScheduled = false
self.reportPinnedHeaderFraction(force: force)
}
}
private func calculatePinnedHeaderFraction() -> CGFloat {
guard collectionView != nil,
view.window != nil,
collectionView.window != nil,
!pinnedDialogs.isEmpty else { return 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 {
guard let indexPath = collectionView.indexPath(for: cell),
sectionForIndexPath(indexPath) == .pinned else {
continue
}
let visibleMaxY = cell.frame.maxY - offset
maxPinnedVisibleOffset = max(maxPinnedVisibleOffset, visibleMaxY)
foundAny = true
}
if !foundAny { return 0.0 }
let viewportInsetTop = collectionView.contentInset.top
guard viewportInsetTop > 0 else { return 0.0 }
if maxPinnedVisibleOffset >= viewportInsetTop {
return 1.0
}
return max(0.0, min(1.0, maxPinnedVisibleOffset / viewportInsetTop))
}
private func reportPinnedHeaderFraction(force: Bool = false) {
let fraction = calculatePinnedHeaderFraction()
if !force, abs(fraction - lastReportedPinnedHeaderFraction) < 0.005 {
return
}
lastReportedPinnedHeaderFraction = fraction
onPinnedHeaderFractionChange?(fraction)
}
private func createLayout() -> UICollectionViewCompositionalLayout {
var listConfig = UICollectionLayoutListConfiguration(appearance: .plain)
listConfig.showsSeparators = false
listConfig.backgroundColor = .clear
// Swipe actions
listConfig.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in
self?.trailingSwipeActions(for: indexPath)
}
listConfig.leadingSwipeActionsConfigurationProvider = { [weak self] indexPath in
self?.leadingSwipeActions(for: indexPath)
}
let layout = UICollectionViewCompositionalLayout { [weak self] sectionIndex, environment in
let section = NSCollectionLayoutSection.list(using: listConfig, layoutEnvironment: environment)
section.interGroupSpacing = 0
// Add pinned section background decoration to pinned AND requests sections
// (requests sit above pinned, so they share the same gray background)
if let self,
sectionIndex < self.dataSource?.snapshot().sectionIdentifiers.count ?? 0,
!self.pinnedDialogs.isEmpty {
let sectionId = self.dataSource?.snapshot().sectionIdentifiers[sectionIndex]
if sectionId == .pinned || sectionId == .requests {
let bgItem = NSCollectionLayoutDecorationItem.background(
elementKind: PinnedSectionBackgroundView.elementKind
)
section.decorationItems = [bgItem]
}
}
return section
}
layout.register(
PinnedSectionBackgroundView.self,
forDecorationViewOfKind: PinnedSectionBackgroundView.elementKind
)
return layout
}
// MARK: - Cell Registrations
private func setupCellRegistrations() {
cellRegistration = UICollectionView.CellRegistration<ChatListCell, Dialog> {
[weak self] cell, indexPath, dialog in
guard let self else { return }
let typingUsers = self.typingDialogs[dialog.opponentKey]
cell.configure(with: dialog, isSyncing: self.isSyncing, typingUsers: typingUsers)
// Separator rules:
// 1) last pinned row -> full-width separator
// 2) last unpinned row -> hidden separator
// 3) others -> regular inset separator
let section = self.sectionForIndexPath(indexPath)
let isLastInPinned = section == .pinned && indexPath.item == self.pinnedDialogs.count - 1
let isLastInUnpinned = section == .unpinned && indexPath.item == self.unpinnedDialogs.count - 1
if isLastInPinned {
cell.setSeparatorStyle(hidden: false, fullWidth: true)
} else {
cell.setSeparatorStyle(hidden: isLastInUnpinned, fullWidth: false)
}
}
requestsCellRegistration = UICollectionView.CellRegistration<ChatListRequestsCell, Int> {
[weak self] cell, _, count in
// Always show separator under requests row
cell.configure(count: count, showBottomSeparator: true)
}
}
// MARK: - Data Source
private func setupDataSource() {
dataSource = UICollectionViewDiffableDataSource<Section, String>(
collectionView: collectionView
) { [weak self] collectionView, indexPath, itemId in
guard let self else { return UICollectionViewCell() }
// CRITICAL: use sectionIdentifier, NOT rawValue mapping.
// When sections are skipped (e.g. no requests), indexPath.section=0
// could be .pinned, not .requests. rawValue mapping would be wrong.
let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section]
if section == .requests {
return collectionView.dequeueConfiguredReusableCell(
using: self.requestsCellRegistration,
for: indexPath,
item: self.requestsCount
)
}
guard let dialog = self.dialogMap[itemId] else {
return UICollectionViewCell()
}
return collectionView.dequeueConfiguredReusableCell(
using: self.cellRegistration,
for: indexPath,
item: dialog
)
}
}
// MARK: - Update Data
func updateDialogs(pinned: [Dialog], unpinned: [Dialog], requestsCount: Int,
typingDialogs: [String: Set<String>], isSyncing: Bool) {
self.typingDialogs = typingDialogs
self.isSyncing = isSyncing
// Check if structure changed (IDs or order)
let oldPinnedIds = self.pinnedDialogs.map(\.id)
let oldUnpinnedIds = self.unpinnedDialogs.map(\.id)
let newPinnedIds = pinned.map(\.id)
let newUnpinnedIds = unpinned.map(\.id)
let structureChanged = oldPinnedIds != newPinnedIds
|| oldUnpinnedIds != newUnpinnedIds
|| self.requestsCount != requestsCount
self.pinnedDialogs = pinned
self.unpinnedDialogs = unpinned
self.requestsCount = requestsCount
// Build lookup map
dialogMap.removeAll(keepingCapacity: true)
for d in pinned { dialogMap[d.id] = d }
for d in unpinned { dialogMap[d.id] = d }
if structureChanged {
// Structure changed rebuild snapshot (animate inserts/deletes/moves)
var snapshot = NSDiffableDataSourceSnapshot<Section, String>()
if requestsCount > 0 {
snapshot.appendSections([.requests])
snapshot.appendItems(["__requests__"], toSection: .requests)
}
if !pinned.isEmpty {
snapshot.appendSections([.pinned])
snapshot.appendItems(newPinnedIds, toSection: .pinned)
}
snapshot.appendSections([.unpinned])
snapshot.appendItems(newUnpinnedIds, toSection: .unpinned)
dataSource.apply(snapshot, animatingDifferences: true) { [weak self] in
self?.reportPinnedHeaderFraction(force: true)
}
} else {
reportPinnedHeaderFraction()
}
// Always reconfigure ONLY visible cells (cheap just updates content, no layout rebuild)
reconfigureVisibleCells()
// Notify host immediately so top chrome reacts in the same frame.
onPinnedStateChange?(!pinned.isEmpty)
updateOverscrollBackground()
}
/// Directly reconfigure only visible cells no snapshot rebuild, no animation.
/// This is the cheapest way to update cell content (online, read status, badges).
private func reconfigureVisibleCells() {
for cell in collectionView.visibleCells {
guard let indexPath = collectionView.indexPath(for: cell) else { continue }
guard let itemId = dataSource.itemIdentifier(for: indexPath) else { continue }
if let chatCell = cell as? ChatListCell, let dialog = dialogMap[itemId] {
let typingUsers = typingDialogs[dialog.opponentKey]
chatCell.configure(with: dialog, isSyncing: isSyncing, typingUsers: typingUsers)
} else if let reqCell = cell as? ChatListRequestsCell {
reqCell.configure(count: requestsCount, showBottomSeparator: true)
}
}
}
// MARK: - Scroll to Top
private func setupScrollToTop() {
NotificationCenter.default.addObserver(
self, selector: #selector(handleScrollToTop),
name: .chatListScrollToTop, object: nil
)
}
@objc private func handleScrollToTop() {
guard collectionView.numberOfSections > 0,
collectionView.numberOfItems(inSection: 0) > 0 else { return }
collectionView.scrollToItem(
at: IndexPath(item: 0, section: 0),
at: .top,
animated: true
)
// Reset search bar expansion
lastReportedExpansion = 1.0
onScrollOffsetChange?(1.0)
}
// MARK: - Swipe Actions
private func sectionForIndexPath(_ indexPath: IndexPath) -> Section? {
let identifiers = dataSource.snapshot().sectionIdentifiers
guard indexPath.section < identifiers.count else { return nil }
return identifiers[indexPath.section]
}
private func trailingSwipeActions(for indexPath: IndexPath) -> UISwipeActionsConfiguration? {
guard let section = sectionForIndexPath(indexPath),
section != .requests else { return nil }
let dialog = dialogForIndexPath(indexPath)
guard let dialog else { return nil }
// Delete
let delete = UIContextualAction(style: .destructive, title: nil) { [weak self] _, _, completion in
DispatchQueue.main.async { self?.onDeleteDialog?(dialog) }
completion(true)
}
delete.image = UIImage(systemName: "trash.fill")
delete.backgroundColor = UIColor(red: 1, green: 0.23, blue: 0.19, alpha: 1)
// Mute/Unmute (skip for Saved Messages)
guard !dialog.isSavedMessages else {
return UISwipeActionsConfiguration(actions: [delete])
}
let mute = UIContextualAction(style: .normal, title: nil) { [weak self] _, _, completion in
DispatchQueue.main.async { self?.onToggleMute?(dialog) }
completion(true)
}
mute.image = UIImage(systemName: dialog.isMuted ? "bell.fill" : "bell.slash.fill")
mute.backgroundColor = dialog.isMuted
? UIColor.systemGreen
: UIColor(red: 1, green: 0.58, blue: 0, alpha: 1) // orange
return UISwipeActionsConfiguration(actions: [delete, mute])
}
private func leadingSwipeActions(for indexPath: IndexPath) -> UISwipeActionsConfiguration? {
guard let section = sectionForIndexPath(indexPath),
section != .requests else { return nil }
let dialog = dialogForIndexPath(indexPath)
guard let dialog else { return nil }
let pin = UIContextualAction(style: .normal, title: nil) { [weak self] _, _, completion in
DispatchQueue.main.async { self?.onTogglePin?(dialog) }
completion(true)
}
pin.image = UIImage(systemName: dialog.isPinned ? "pin.slash.fill" : "pin.fill")
pin.backgroundColor = UIColor(red: 1, green: 0.58, blue: 0, alpha: 1) // orange
let config = UISwipeActionsConfiguration(actions: [pin])
config.performsFirstActionWithFullSwipe = true
return config
}
private func dialogForIndexPath(_ indexPath: IndexPath) -> Dialog? {
guard let itemId = dataSource.itemIdentifier(for: indexPath) else { return nil }
return dialogMap[itemId]
}
}
// MARK: - UICollectionViewDelegate
extension ChatListCollectionController: UICollectionViewDelegate {
// MARK: - Scroll-Linked Search Bar (Telegram: 54pt collapse distance)
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// Only react to user-driven scroll, not programmatic/layout changes
guard scrollView.isDragging || scrollView.isDecelerating else { return }
let offset = scrollView.contentOffset.y + view.safeAreaInsets.top + customHeaderBarHeight + searchCollapseDistance
let expansion = max(0.0, min(1.0, 1.0 - offset / searchCollapseDistance))
if abs(expansion - lastReportedExpansion) > 0.005 {
lastReportedExpansion = expansion
onScrollOffsetChange?(expansion)
}
reportPinnedHeaderFraction()
updateOverscrollBackground()
}
func scrollViewWillEndDragging(
_ scrollView: UIScrollView,
withVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer<CGPoint>
) {
// Telegram snap-to-edge: if search bar is partially visible, snap to
// fully visible (>50%) or fully hidden (<50%).
guard lastReportedExpansion > 0.0 && lastReportedExpansion < 1.0 else { return }
let headerTop = view.safeAreaInsets.top + customHeaderBarHeight
if lastReportedExpansion < 0.5 {
targetContentOffset.pointee.y = -headerTop
} else {
targetContentOffset.pointee.y = -(headerTop + searchCollapseDistance)
}
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
collectionView.deselectItem(at: indexPath, animated: true)
guard let section = sectionForIndexPath(indexPath) else { return }
if section == .requests {
DispatchQueue.main.async { [weak self] in
self?.onShowRequests?()
}
return
}
guard let dialog = dialogForIndexPath(indexPath) else { return }
DispatchQueue.main.async { [weak self] in
self?.onSelectDialog?(dialog)
}
}
// MARK: - Context Menu (Long Press)
func collectionView(
_ collectionView: UICollectionView,
contextMenuConfigurationForItemAt indexPath: IndexPath,
point: CGPoint
) -> UIContextMenuConfiguration? {
guard let section = sectionForIndexPath(indexPath),
section != .requests,
let dialog = dialogForIndexPath(indexPath) else { return nil }
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in
guard let self else { return nil }
let pinTitle = dialog.isPinned ? "Unpin" : "Pin"
let pinImage = UIImage(systemName: dialog.isPinned ? "pin.slash" : "pin")
let pinAction = UIAction(title: pinTitle, image: pinImage) { [weak self] _ in
DispatchQueue.main.async { self?.onTogglePin?(dialog) }
}
var actions: [UIAction] = [pinAction]
if !dialog.isSavedMessages {
let muteTitle = dialog.isMuted ? "Unmute" : "Mute"
let muteImage = UIImage(systemName: dialog.isMuted ? "bell" : "bell.slash")
let muteAction = UIAction(title: muteTitle, image: muteImage) { [weak self] _ in
DispatchQueue.main.async { self?.onToggleMute?(dialog) }
}
actions.append(muteAction)
}
if dialog.unreadCount > 0 {
let readAction = UIAction(
title: "Mark as Read",
image: UIImage(systemName: "checkmark.message")
) { [weak self] _ in
DispatchQueue.main.async { self?.onMarkAsRead?(dialog) }
}
actions.append(readAction)
}
let deleteAction = UIAction(
title: "Delete",
image: UIImage(systemName: "trash"),
attributes: .destructive
) { [weak self] _ in
DispatchQueue.main.async { self?.onDeleteDialog?(dialog) }
}
actions.append(deleteAction)
return UIMenu(children: actions)
}
}
}
// MARK: - UICollectionViewDataSourcePrefetching
extension ChatListCollectionController: UICollectionViewDataSourcePrefetching {
func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
guard let itemId = dataSource.itemIdentifier(for: indexPath),
let dialog = dialogMap[itemId],
!dialog.isSavedMessages else { continue }
// Warm avatar cache on background queue
let key = dialog.opponentKey
DispatchQueue.global(qos: .userInitiated).async {
_ = AvatarRepository.shared.loadAvatar(publicKey: key)
}
}
}
}
// MARK: - Request Chats Cell
/// Simple cell for "Request Chats" row at the top (like Telegram's Archived Chats).
final class ChatListRequestsCell: UICollectionViewCell {
private let avatarCircle = UIView()
private let iconView = UIImageView()
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let separatorView = UIView()
override init(frame: CGRect) {
super.init(frame: frame)
setupSubviews()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupSubviews() {
backgroundColor = .clear
contentView.backgroundColor = .clear
avatarCircle.backgroundColor = UIColor(RosettaColors.primaryBlue)
avatarCircle.layer.cornerRadius = 30
avatarCircle.clipsToBounds = true
contentView.addSubview(avatarCircle)
iconView.image = UIImage(systemName: "tray.and.arrow.down")?.withConfiguration(
UIImage.SymbolConfiguration(pointSize: 24, weight: .medium)
)
iconView.tintColor = .white
iconView.contentMode = .center
contentView.addSubview(iconView)
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
titleLabel.text = "Request Chats"
contentView.addSubview(titleLabel)
subtitleLabel.font = .systemFont(ofSize: 15, weight: .regular)
contentView.addSubview(subtitleLabel)
separatorView.isUserInteractionEnabled = false
contentView.addSubview(separatorView)
}
override func layoutSubviews() {
super.layoutSubviews()
let h = contentView.bounds.height
let w = contentView.bounds.width
let avatarY = floor((h - 60) / 2)
avatarCircle.frame = CGRect(x: 10, y: avatarY, width: 60, height: 60)
iconView.frame = avatarCircle.frame
titleLabel.frame = CGRect(x: 80, y: 14, width: w - 96, height: 22)
subtitleLabel.frame = CGRect(x: 80, y: 36, width: w - 96, height: 20)
let sepH = 1.0 / UIScreen.main.scale
separatorView.frame = CGRect(x: 80, y: h - sepH, width: w - 80, height: sepH)
}
func configure(count: Int, showBottomSeparator: Bool) {
let isDark = traitCollection.userInterfaceStyle == .dark
titleLabel.textColor = isDark ? .white : .black
subtitleLabel.text = count == 1 ? "1 request" : "\(count) requests"
subtitleLabel.textColor = UIColor(red: 0x8E/255, green: 0x8E/255, blue: 0x93/255, alpha: 1)
separatorView.backgroundColor = isDark
? UIColor(red: 0x54/255, green: 0x54/255, blue: 0x58/255, alpha: 0.55)
: UIColor(red: 0xC8/255, green: 0xC7/255, blue: 0xCC/255, alpha: 1)
separatorView.isHidden = !showBottomSeparator
}
override func preferredLayoutAttributesFitting(
_ layoutAttributes: UICollectionViewLayoutAttributes
) -> UICollectionViewLayoutAttributes {
let attrs = layoutAttributes.copy() as! UICollectionViewLayoutAttributes
attrs.size.height = 76
return attrs
}
}
// MARK: - ChatListCell Self-Sizing Override
extension ChatListCell {
override func preferredLayoutAttributesFitting(
_ layoutAttributes: UICollectionViewLayoutAttributes
) -> UICollectionViewLayoutAttributes {
let attrs = layoutAttributes.copy() as! UICollectionViewLayoutAttributes
attrs.size.height = ChatListCell.CellLayout.itemHeight
return attrs
}
}