eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import LChatCore
2import SwiftUI
3import UIKit
4
5// The chat list, backed by a UICollectionView list instead of SwiftUI's List.
6//
7// Why UIKit: SwiftUI's List + NavigationLink doesn't reproduce UITableView's stock
8// row behaviour — a quick tap shows no press highlight (the pressed state renders
9// too late). A UICollectionView list gives it for free: a row highlights on a
10// settled touch, stays selected through the push (fading on return), and never
11// highlights when a finger lands and drags to scroll. Cells render the same SwiftUI
12// ThreadRow via UIHostingConfiguration, so the look is unchanged. The SwiftUI
13// NavigationStack / .searchable / toolbar scaffolding stays around this; it's only
14// the scrolling content. (See the ios-native-uikit-direction note — NetNewsWire is
15// the reference for feeling natively UIKit.)
16struct ThreadListCollection: UIViewControllerRepresentable {
17 var threads: [ChatThread]
18 var onSelect: (String) -> Void
19 var onClose: (ChatThread) -> Void
20 var onPin: (ChatThread) -> Void
21 var onToggleUnread: (ChatThread) -> Void
22 var onMute: (ChatThread) -> Void
23
24 func makeUIViewController(context: Context) -> ThreadListController { ThreadListController() }
25
26 func updateUIViewController(_ vc: ThreadListController, context: Context) {
27 vc.onSelect = onSelect
28 vc.onClose = onClose
29 vc.onPin = onPin
30 vc.onToggleUnread = onToggleUnread
31 vc.onMute = onMute
32 vc.apply(threads)
33 }
34}
35
36final class ThreadListController: UICollectionViewController {
37 private enum Section { case main }
38
39 var onSelect: ((String) -> Void)?
40 var onClose: ((ChatThread) -> Void)?
41 var onPin: ((ChatThread) -> Void)?
42 var onToggleUnread: ((ChatThread) -> Void)?
43 var onMute: ((ChatThread) -> Void)?
44
45 private var threadsByID: [String: ChatThread] = [:]
46 private var latestThreads: [ChatThread] = []
47 private var dataSource: UICollectionViewDiffableDataSource<Section, String>!
48 private var populated = false
49 // Mirrors the applied snapshot's row count for the separator handler.
50 private var itemCount = 0
51
52 init() {
53 // A placeholder list layout; viewDidLoad swaps in the real one, which needs
54 // `self` for the swipe provider (not referenceable before super.init).
55 super.init(collectionViewLayout: UICollectionViewCompositionalLayout.list(
56 using: UICollectionLayoutListConfiguration(appearance: .plain)))
57 // We deselect the pushed row ourselves in viewWillAppear so it fades out
58 // alongside the pop (UIKit's clearsSelection would drop it with no animation).
59 clearsSelectionOnViewWillAppear = false
60 }
61 required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
62
63 override func viewDidLoad() {
64 super.viewDidLoad()
65 collectionView.backgroundColor = .systemBackground
66
67 var config = UICollectionLayoutListConfiguration(appearance: .plain)
68 // Native swipe-to-close — the list mirror of the in-chat "Close chat" —
69 // plus mute, Mail's trailing-cluster idiom (Close stays outermost, so
70 // a full swipe still closes).
71 config.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in
72 guard let self,
73 let id = self.dataSource.itemIdentifier(for: indexPath),
74 let thread = self.threadsByID[id] else { return nil }
75 let close = UIContextualAction(style: .destructive, title: "Close") { _, _, done in
76 self.onClose?(thread)
77 done(true)
78 }
79 close.image = UIImage(systemName: "xmark.circle")
80 let mute = UIContextualAction(style: .normal,
81 title: thread.isMuted ? "Unmute" : "Mute") { _, _, done in
82 self.onMute?(thread)
83 done(true)
84 }
85 mute.image = UIImage(systemName: thread.isMuted ? "bell" : "bell.slash")
86 mute.backgroundColor = .systemIndigo
87 return UISwipeActionsConfiguration(actions: [close, mute])
88 }
89 // Swipe right: toggle read/unread — Mail's idiom.
90 config.leadingSwipeActionsConfigurationProvider = { [weak self] indexPath in
91 guard let self,
92 let id = self.dataSource.itemIdentifier(for: indexPath),
93 let thread = self.threadsByID[id] else { return nil }
94 let toggle = UIContextualAction(style: .normal,
95 title: thread.unread ? "Read" : "Unread") { _, _, done in
96 self.onToggleUnread?(thread)
97 done(true)
98 }
99 toggle.image = UIImage(systemName: thread.unread ? "envelope.open" : "envelope.badge")
100 toggle.backgroundColor = .systemBlue
101 return UISwipeActionsConfiguration(actions: [toggle])
102 }
103 // Separators run only *between* rows: no line above the first, none below the
104 // last (a plain list would otherwise cap both ends). The count comes from
105 // the var apply() maintains — dataSource.snapshot() COPIES the whole
106 // snapshot, and this handler runs per row, so reading it here was O(n²)
107 // across a layout pass.
108 config.itemSeparatorHandler = { [weak self] indexPath, proposed in
109 var sep = proposed
110 if indexPath.item == 0 { sep.topSeparatorVisibility = .hidden }
111 if indexPath.item == (self?.itemCount ?? 0) - 1 {
112 sep.bottomSeparatorVisibility = .hidden
113 }
114 return sep
115 }
116 collectionView.setCollectionViewLayout(
117 UICollectionViewCompositionalLayout.list(using: config), animated: false)
118
119 let cell = UICollectionView.CellRegistration<UICollectionViewListCell, String> { [weak self] cell, _, id in
120 guard let thread = self?.threadsByID[id] else { return }
121 cell.contentConfiguration = UIHostingConfiguration { ThreadRow(thread: thread) }
122 .margins(.vertical, 10)
123 cell.accessories = [.disclosureIndicator()]
124 }
125 dataSource = UICollectionViewDiffableDataSource<Section, String>(collectionView: collectionView) {
126 cv, indexPath, id in
127 cv.dequeueConfiguredReusableCell(using: cell, for: indexPath, item: id)
128 }
129
130 apply(latestThreads) // data set before the view loaded
131 }
132
133 override func viewWillAppear(_ animated: Bool) {
134 super.viewWillAppear(animated)
135 // Coming back from a chat: fade the row's selection out alongside the pop,
136 // and restore it if the back gesture is cancelled — the standard UIKit idiom.
137 guard let selected = collectionView.indexPathsForSelectedItems?.first else { return }
138 guard let coordinator = transitionCoordinator else {
139 collectionView.deselectItem(at: selected, animated: animated)
140 return
141 }
142 coordinator.animate(alongsideTransition: { _ in
143 self.collectionView.deselectItem(at: selected, animated: true)
144 }, completion: { ctx in
145 if ctx.isCancelled {
146 self.collectionView.selectItem(at: selected, animated: false, scrollPosition: [])
147 }
148 })
149 }
150
151 override func viewDidAppear(_ animated: Bool) {
152 super.viewDidAppear(animated)
153 // Let SwiftUI's navigation bar track our (UIKit) scroll view so its scroll-edge
154 // appearance transitions correctly (transparent at the top, material once
155 // scrolled). The bar reads the top view controller's content scroll view; ours
156 // lives in a child VC, so hand it to the nearest ancestor the nav controller manages.
157 var top: UIViewController = self
158 while let parent = top.parent, !(parent is UINavigationController) { top = parent }
159 top.setContentScrollView(collectionView, for: .top)
160 }
161
162 func apply(_ threads: [ChatThread]) {
163 latestThreads = threads
164 guard isViewLoaded, dataSource != nil else { return } // deferred to viewDidLoad
165 let previous = threadsByID
166 threadsByID = Dictionary(threads.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
167 let ids = threads.map(\.id)
168 itemCount = ids.count
169 var snap = NSDiffableDataSourceSnapshot<Section, String>()
170 snap.appendSections([.main])
171 snap.appendItems(ids, toSection: .main)
172 // Surviving rows re-render (fresh preview / time / unread dot) — but
173 // only the ones that actually changed: each reconfigure rebuilds a
174 // UIHostingConfiguration on the main thread, and this runs on every
175 // store publish, so one flipped unread dot must not re-host every
176 // row (#465).
177 let changed = ids.filter { id in
178 guard let old = previous[id] else { return false } // new row: the snapshot inserts it
179 return old != threadsByID[id]
180 }
181 if !changed.isEmpty { snap.reconfigureItems(changed) }
182 dataSource.apply(snap, animatingDifferences: populated)
183 populated = true
184 }
185
186 override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
187 guard let id = dataSource.itemIdentifier(for: indexPath) else { return }
188 onSelect?(id) // pushes via the SwiftUI NavigationStack; the row stays selected until we return
189 }
190
191 // Long-press: the row's action menu (pin / unread toggle / close) — the
192 // stock UIKit interaction, matching the Mac list's right-click menu.
193 override func collectionView(_ collectionView: UICollectionView,
194 contextMenuConfigurationForItemAt indexPath: IndexPath,
195 point: CGPoint) -> UIContextMenuConfiguration? {
196 guard let id = dataSource.itemIdentifier(for: indexPath),
197 let thread = threadsByID[id] else { return nil }
198 return UIContextMenuConfiguration(actionProvider: { _ in
199 UIMenu(children: [
200 UIAction(title: thread.pinned ? "Unpin" : "Pin",
201 image: UIImage(systemName: thread.pinned ? "pin.slash" : "pin")) { [weak self] _ in
202 self?.onPin?(thread)
203 },
204 UIAction(title: thread.isMuted ? "Unmute chat" : "Mute chat",
205 image: UIImage(systemName: thread.isMuted ? "bell" : "bell.slash")) { [weak self] _ in
206 self?.onMute?(thread)
207 },
208 UIAction(title: thread.unread ? "Mark as Read" : "Mark as Unread",
209 image: UIImage(systemName: thread.unread ? "envelope.open" : "envelope.badge")) { [weak self] _ in
210 self?.onToggleUnread?(thread)
211 },
212 UIAction(title: "Close", image: UIImage(systemName: "xmark.circle"),
213 attributes: .destructive) { [weak self] _ in
214 self?.onClose?(thread)
215 },
216 ])
217 })
218 }
219}
220