eleven / messenger Public

ios: render the chat list as a native UICollectionView (#140)

SwiftUI's List + NavigationLink doesn't reproduce UITableView's stock row behaviour — a quick tap shows no press highlight (the pressed state renders too late), and forcing it on (delaysContentTouches=false) flashes the highlight on scroll. Replace the List with a UICollectionView list (UIViewControllerRepresentable) whose cells render the same SwiftUI ThreadRow via UIHostingConfiguration, so rows highlight on a settled touch, keep their selection through the push (fading on return), and never flash on scroll — the native UITableView feel, à la NetNewsWire.

Also: native swipe-to-close, 2-line previews, separators only between rows, a NetNewsWire-style inline two-line title (server name over host), and the list scrolls under a frosted nav bar (setContentScrollView drives the scroll-edge appearance).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 35ce6bc709089ff7581d51edd7de80fce522b618 parent cf58ddb
3 files changed, +252 −68
  • ios/LChat/Views/ThreadListCollection.swift +163 −0
  • ios/LChat/Views/ThreadListView.swift +32 −68
  • ios/LChat/Views/ThreadRow.swift +57 −0
diff --git a/ios/LChat/Views/ThreadListCollection.swift b/ios/LChat/Views/ThreadListCollection.swift
new file mode 100644
index 0000000..83d4de7
--- /dev/null
+++ b/ios/LChat/Views/ThreadListCollection.swift
@@ -0,0 +1,163 @@
+import SwiftUI
+import UIKit
+
+// The chat list, backed by a UICollectionView list instead of SwiftUI's List.
+//
+// Why UIKit: SwiftUI's List + NavigationLink doesn't reproduce UITableView's stock
+// row behaviour — a quick tap shows no press highlight (the pressed state renders
+// too late). A UICollectionView list gives it for free: a row highlights on a
+// settled touch, stays selected through the push (fading on return), and never
+// highlights when a finger lands and drags to scroll. Cells render the same SwiftUI
+// ThreadRow via UIHostingConfiguration, so the look is unchanged. The SwiftUI
+// NavigationStack / .searchable / toolbar scaffolding stays around this; it's only
+// the scrolling content. (See the ios-native-uikit-direction note — NetNewsWire is
+// the reference for feeling natively UIKit.)
+struct ThreadListCollection: UIViewControllerRepresentable {
+ var threads: [Thread]
+ var lockScroll: Bool
+ var onSelect: (String) -> Void
+ var onClose: (Thread) -> Void
+ var onRefresh: () async -> Void
+
+ func makeUIViewController(context: Context) -> ThreadListController { ThreadListController() }
+
+ func updateUIViewController(_ vc: ThreadListController, context: Context) {
+ vc.onSelect = onSelect
+ vc.onClose = onClose
+ vc.onRefresh = onRefresh
+ // Freeze scrolling while an edge-drag is opening the server drawer (mirrors
+ // the old .scrollDisabled(lockScroll)).
+ vc.collectionView.isScrollEnabled = !lockScroll
+ vc.apply(threads)
+ }
+}
+
+final class ThreadListController: UICollectionViewController {
+ private enum Section { case main }
+
+ var onSelect: ((String) -> Void)?
+ var onClose: ((Thread) -> Void)?
+ var onRefresh: (() async -> Void)?
+
+ private var threadsByID: [String: Thread] = [:]
+ private var latestThreads: [Thread] = []
+ private var dataSource: UICollectionViewDiffableDataSource<Section, String>!
+ private var populated = false
+
+ init() {
+ // A placeholder list layout; viewDidLoad swaps in the real one, which needs
+ // `self` for the swipe provider (not referenceable before super.init).
+ super.init(collectionViewLayout: UICollectionViewCompositionalLayout.list(
+ using: UICollectionLayoutListConfiguration(appearance: .plain)))
+ // We deselect the pushed row ourselves in viewWillAppear so it fades out
+ // alongside the pop (UIKit's clearsSelection would drop it with no animation).
+ clearsSelectionOnViewWillAppear = false
+ }
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ collectionView.backgroundColor = .systemBackground
+
+ var config = UICollectionLayoutListConfiguration(appearance: .plain)
+ // Native swipe-to-close — the list mirror of the in-chat "Close chat".
+ config.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in
+ guard let self,
+ let id = self.dataSource.itemIdentifier(for: indexPath),
+ let thread = self.threadsByID[id] else { return nil }
+ let close = UIContextualAction(style: .destructive, title: "Close") { _, _, done in
+ self.onClose?(thread)
+ done(true)
+ }
+ close.image = UIImage(systemName: "xmark.circle")
+ return UISwipeActionsConfiguration(actions: [close])
+ }
+ // Separators run only *between* rows: no line above the first, none below the
+ // last (a plain list would otherwise cap both ends).
+ config.itemSeparatorHandler = { [weak self] indexPath, proposed in
+ var sep = proposed
+ if indexPath.item == 0 { sep.topSeparatorVisibility = .hidden }
+ if let n = self?.dataSource.snapshot().numberOfItems(inSection: .main),
+ indexPath.item == n - 1 {
+ sep.bottomSeparatorVisibility = .hidden
+ }
+ return sep
+ }
+ collectionView.setCollectionViewLayout(
+ UICollectionViewCompositionalLayout.list(using: config), animated: false)
+
+ let cell = UICollectionView.CellRegistration<UICollectionViewListCell, String> { [weak self] cell, _, id in
+ guard let thread = self?.threadsByID[id] else { return }
+ cell.contentConfiguration = UIHostingConfiguration { ThreadRow(thread: thread) }
+ .margins(.vertical, 7)
+ cell.accessories = [.disclosureIndicator()]
+ }
+ dataSource = UICollectionViewDiffableDataSource<Section, String>(collectionView: collectionView) {
+ cv, indexPath, id in
+ cv.dequeueConfiguredReusableCell(using: cell, for: indexPath, item: id)
+ }
+
+ let refresh = UIRefreshControl()
+ refresh.addTarget(self, action: #selector(handleRefresh), for: .valueChanged)
+ collectionView.refreshControl = refresh
+
+ apply(latestThreads) // data set before the view loaded
+ }
+
+ override func viewWillAppear(_ animated: Bool) {
+ super.viewWillAppear(animated)
+ // Coming back from a chat: fade the row's selection out alongside the pop,
+ // and restore it if the back gesture is cancelled — the standard UIKit idiom.
+ guard let selected = collectionView.indexPathsForSelectedItems?.first else { return }
+ guard let coordinator = transitionCoordinator else {
+ collectionView.deselectItem(at: selected, animated: animated)
+ return
+ }
+ coordinator.animate(alongsideTransition: { _ in
+ self.collectionView.deselectItem(at: selected, animated: true)
+ }, completion: { ctx in
+ if ctx.isCancelled {
+ self.collectionView.selectItem(at: selected, animated: false, scrollPosition: [])
+ }
+ })
+ }
+
+ override func viewDidAppear(_ animated: Bool) {
+ super.viewDidAppear(animated)
+ // Let SwiftUI's navigation bar track our (UIKit) scroll view so its scroll-edge
+ // appearance transitions correctly (transparent at the top, material once
+ // scrolled). The bar reads the top view controller's content scroll view; ours
+ // lives in a child VC, so hand it to the nearest ancestor the nav controller manages.
+ var top: UIViewController = self
+ while let parent = top.parent, !(parent is UINavigationController) { top = parent }
+ top.setContentScrollView(collectionView, for: .top)
+ }
+
+ @objc private func handleRefresh() {
+ Task { @MainActor in
+ await onRefresh?()
+ collectionView.refreshControl?.endRefreshing()
+ }
+ }
+
+ func apply(_ threads: [Thread]) {
+ latestThreads = threads
+ guard isViewLoaded, dataSource != nil else { return } // deferred to viewDidLoad
+ let existing = Set(threadsByID.keys)
+ threadsByID = Dictionary(threads.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
+ let ids = threads.map(\.id)
+ var snap = NSDiffableDataSourceSnapshot<Section, String>()
+ snap.appendSections([.main])
+ snap.appendItems(ids, toSection: .main)
+ // Rows that survive may have a fresh preview / time / unread dot — re-render them.
+ let survivors = ids.filter { existing.contains($0) }
+ if !survivors.isEmpty { snap.reconfigureItems(survivors) }
+ dataSource.apply(snap, animatingDifferences: populated)
+ populated = true
+ }
+
+ override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
+ guard let id = dataSource.itemIdentifier(for: indexPath) else { return }
+ onSelect?(id) // pushes via the SwiftUI NavigationStack; the row stays selected until we return
+ }
+}
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift
index 9c0eab9..466227e 100644
--- a/ios/LChat/Views/ThreadListView.swift
+++ b/ios/LChat/Views/ThreadListView.swift
@@ -24,23 +24,25 @@ struct ThreadListView: View {
return store.threads.filter { $0.title.localizedCaseInsensitiveContains(q) }
}
+ // The active server's name (falls back to the host until the group name loads).
+ private var serverTitle: String {
+ store.instanceName.isEmpty ? store.instance.displayName : store.instanceName
+ }
+
var body: some View {
- List {
- ForEach(visibleThreads) { t in
- NavigationLink(value: t.id) { row(t) }
- // Swipe a row to close (hide) the chat — the list mirror of the
- // in-chat menu's "Close chat". It stays findable via search.
- .swipeActions(edge: .trailing) {
- Button(role: .destructive) {
- store.setHidden(t, true)
- } label: {
- Label("Close", systemImage: "xmark.circle")
- }
- }
- }
- }
- .listStyle(.plain)
- .scrollDisabled(lockScroll)
+ // A UIKit UICollectionView list, not SwiftUI's List — see ThreadListCollection
+ // for why (stock UITableView row highlight that SwiftUI's List can't match).
+ ThreadListCollection(
+ threads: visibleThreads,
+ lockScroll: lockScroll,
+ onSelect: { path.append($0) },
+ onClose: { store.setHidden($0, true) },
+ onRefresh: { await load() }
+ )
+ // Extend under the nav bar (and home indicator) so the list scrolls *behind*
+ // the frosted bar; the collection view's automatic content inset still rests
+ // the first row below the bar, and setContentScrollView frosts it once scrolled.
+ .ignoresSafeArea(edges: [.top, .bottom])
.searchable(text: $search, prompt: "Search chats")
.overlay { if store.loading && store.threads.isEmpty { ProgressView() } }
.overlay { if !store.loading && store.threads.isEmpty { empty } }
@@ -49,16 +51,24 @@ struct ThreadListView: View {
ContentUnavailableView.search(text: search)
}
}
- .navigationTitle("Chats")
- .navigationBarTitleDisplayMode(.large)
+ .navigationTitle(serverTitle) // backs the chat screen's back button
+ .navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button { showServers = true } label: {
- Avatar(name: store.instanceName.isEmpty ? store.instance.displayName : store.instanceName,
- size: 30)
+ Avatar(name: serverTitle, size: 30)
}
.accessibilityLabel("Servers")
}
+ // Centered two-line title: server name over its host (the NetNewsWire
+ // pattern). A large collapsing title doesn't work over the UIKit list.
+ ToolbarItem(placement: .principal) {
+ VStack(spacing: 0) {
+ Text(serverTitle).font(.headline).lineLimit(1)
+ Text(store.instance.hostLabel).font(.caption).foregroundStyle(.secondary).lineLimit(1)
+ }
+ .accessibilityElement(children: .combine)
+ }
ToolbarItem(placement: .primaryAction) {
Button { showNew = true } label: { Image(systemName: "square.and.pencil") }
.accessibilityLabel("New chat")
@@ -70,45 +80,14 @@ struct ThreadListView: View {
.navigationDestination(for: String.self) { id in
ThreadDestination(store: store, id: id)
}
- .refreshable { await load() }
+ // Pull-to-refresh lives on the UIKit collection view (ThreadListCollection),
+ // not SwiftUI's .refreshable, since the scroll view is now UIKit's.
// Keyed on the instance: when the store swaps in place (switching server),
// SwiftUI reuses this view, so an un-keyed .task would not re-fire and the new
// server's chats would never load.
.task(id: store.instance.id) { await load() }
}
- private func row(_ t: Thread) -> some View {
- HStack(spacing: 12) {
- Avatar(name: t.title, size: 44)
- VStack(alignment: .leading, spacing: 2) {
- HStack {
- Text(t.title).font(.headline).lineLimit(1)
- Spacer()
- HStack(spacing: 4) {
- if t.pinned {
- Image(systemName: "pin.fill")
- .font(.caption2)
- .foregroundStyle(.secondary)
- .accessibilityLabel("Pinned")
- }
- if t.lastAt > 0 {
- Text(relTime(t.lastAt)).font(.caption).foregroundStyle(.secondary)
- }
- }
- }
- HStack(spacing: 6) {
- Text(t.preview.isEmpty ? " " : t.preview)
- .font(.subheadline).foregroundStyle(.secondary).lineLimit(1)
- Spacer()
- if t.unread {
- Circle().fill(Color.accentColor).frame(width: 9, height: 9)
- }
- }
- }
- }
- .padding(.vertical, 4)
- }
-
private var empty: some View {
VStack(spacing: 8) {
Image(systemName: "tray").font(.system(size: 36)).foregroundStyle(.secondary)
@@ -160,18 +139,3 @@ struct ThreadDestination: View {
}
}
}
-
-// A compact "last activity" label — mirrors relTime in web/static/chat.js.
-private func relTime(_ sec: Int64) -> String {
- guard sec > 0 else { return "" }
- let d = Date(timeIntervalSince1970: TimeInterval(sec))
- let cal = Calendar.current
- if cal.isDateInToday(d) {
- return d.formatted(date: .omitted, time: .shortened)
- }
- if cal.isDateInYesterday(d) { return "Yesterday" }
- if let days = cal.dateComponents([.day], from: d, to: Date()).day, days < 7 {
- return d.formatted(.dateTime.weekday(.abbreviated))
- }
- return d.formatted(.dateTime.day().month(.twoDigits))
-}
diff --git a/ios/LChat/Views/ThreadRow.swift b/ios/LChat/Views/ThreadRow.swift
new file mode 100644
index 0000000..e0cb2af
--- /dev/null
+++ b/ios/LChat/Views/ThreadRow.swift
@@ -0,0 +1,57 @@
+import SwiftUI
+
+// One chat row's content — extracted so the UIKit list (ThreadListCollection) can
+// render it inside a UICollectionViewListCell via UIHostingConfiguration. Pure
+// presentation; the cell owns the highlight/selection chrome.
+struct ThreadRow: View {
+ let thread: Thread
+
+ var body: some View {
+ HStack(spacing: 12) {
+ Avatar(name: thread.title, size: 44)
+ VStack(alignment: .leading, spacing: 2) {
+ HStack {
+ Text(thread.title).font(.headline).lineLimit(1)
+ Spacer()
+ HStack(spacing: 4) {
+ if thread.pinned {
+ Image(systemName: "pin.fill")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .accessibilityLabel("Pinned")
+ }
+ if thread.lastAt > 0 {
+ Text(relTime(thread.lastAt)).font(.caption).foregroundStyle(.secondary)
+ }
+ }
+ }
+ HStack(spacing: 6) {
+ // Up to two lines of preview, height reserved so every row is the
+ // same height (à la Mail/Messages) — mirrors the web list's 2-line clamp.
+ Text(thread.preview.isEmpty ? " " : thread.preview)
+ .font(.subheadline).foregroundStyle(.secondary)
+ .lineLimit(2, reservesSpace: true)
+ Spacer()
+ if thread.unread {
+ Circle().fill(Color.accentColor).frame(width: 9, height: 9)
+ }
+ }
+ }
+ }
+ }
+}
+
+// A compact "last activity" label — mirrors relTime in web/static/chat.js.
+func relTime(_ sec: Int64) -> String {
+ guard sec > 0 else { return "" }
+ let d = Date(timeIntervalSince1970: TimeInterval(sec))
+ let cal = Calendar.current
+ if cal.isDateInToday(d) {
+ return d.formatted(date: .omitted, time: .shortened)
+ }
+ if cal.isDateInYesterday(d) { return "Yesterday" }
+ if let days = cal.dateComponents([.day], from: d, to: Date()).day, days < 7 {
+ return d.formatted(.dateTime.weekday(.abbreviated))
+ }
+ return d.formatted(.dateTime.day().month(.twoDigits))
+}