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>
3 files changed,
+252
−68
ios/LChat/Views/ThreadListCollection.swift+163 −0ios/LChat/Views/ThreadListView.swift+32 −68ios/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)) |
| +} |