fix(mobile): diff New DM picker updates and animate picks

Flutter pushes the picker's whole state on every rebuild, and each push
reloaded every visible row with fresh, unreused cells. Drop identical
states, apply the rest as a diffable snapshot that redraws only changed
rows in reused cells, and animate a member's row moving between the
chosen and result sections when it is picked or removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <tomb@squareup.com>
This commit is contained in:
Tom Brow
2026-11-07 16:38:58 -08:00
parent e3e169afcc
commit 0c8ec394c9
2 changed files with 116 additions and 66 deletions

View File

@@ -25,7 +25,7 @@ struct AddMembersIdentityTests {
picker.loadViewIfNeeded()
for index in keys.indices {
let path = IndexPath(row: index, section: 1)
let cell = picker.tableView(picker.tableView, cellForRowAt: path)
let cell = picker.tableView.dataSource!.tableView(picker.tableView, cellForRowAt: path)
let content = cell.contentConfiguration as? UIListContentConfiguration
precondition(content?.text == labels[index], "Visible identity must retain its qualifier")
precondition(cell.isAccessibilityElement, "The row owns accessibility")
@@ -38,13 +38,15 @@ struct AddMembersIdentityTests {
}
precondition(selected == keys, "Each distinct label must select its own pubkey")
avatarReplies.removeAll()
picker.update(["users": users, "selected": [], "query": "", "loading": false])
let rebuilt = picker.tableView(picker.tableView, cellForRowAt: IndexPath(row: 0, section: 1))
let rebuiltImage = (rebuilt.contentConfiguration as? UIListContentConfiguration)?.image
let renamed = [users[0].merging(["detail": "Renamed"]) { _, new in new }, users[1]]
picker.update(["users": renamed, "selected": [], "query": "", "loading": false])
let rebuilt = picker.tableView.dataSource!.tableView(picker.tableView, cellForRowAt: IndexPath(row: 0, section: 1))
let rebuiltContent = rebuilt.contentConfiguration as? UIListContentConfiguration
precondition(rebuiltContent?.secondaryText == "Renamed", "A changed row is redrawn")
precondition(avatarReplies.isEmpty, "A loaded avatar is not fetched again on update")
precondition(rebuiltImage?.pngData() == UIImage(data: avatar!)?.pngData(), "A rebuilt row draws its loaded avatar at once")
precondition(rebuiltContent?.image?.pngData() == UIImage(data: avatar!)?.pngData(), "A rebuilt row draws its loaded avatar at once")
picker.update(["users": users, "selected": [users[1]], "query": "", "loading": false])
let chosen = picker.tableView(picker.tableView, cellForRowAt: IndexPath(row: 0, section: 0))
let chosen = picker.tableView.dataSource!.tableView(picker.tableView, cellForRowAt: IndexPath(row: 0, section: 0))
precondition(chosen.accessibilityLabel == "\(labels[1]), Same owner", "Selected identity retains its qualifier")
precondition(chosen.accessibilityTraits.contains(.selected), "Selection is exposed to VoiceOver")
print("Native member picker identity and accessibility passed")

View File

@@ -164,6 +164,13 @@ final class NativeAddMembersViewController: UITableViewController, UISearchResul
navigationItem.rightBarButtonItem?.accessibilityLabel = "Create message"
}
NavigationTopEdge.configure(tableView, item: navigationItem)
tableView.register(UITableViewCell.self, forCellReuseIdentifier: Self.cellIdentifier)
rows = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in
let cell = tableView.dequeueReusableCell(withIdentifier: Self.cellIdentifier, for: indexPath)
self?.configure(cell, item: item)
return cell
}
rows.defaultRowAnimation = .fade
applyState()
}
@@ -181,6 +188,8 @@ final class NativeAddMembersViewController: UITableViewController, UISearchResul
func update(_ data: [String: Any]) {
// A queued render from before the latest keystroke must not replace results.
guard (data["query"] as? String ?? "") == (search.searchBar.text ?? "") else { return }
// Flutter sends the whole state on every rebuild, often unchanged.
guard !NSDictionary(dictionary: data).isEqual(to: state) else { return }
state = data
if isViewLoaded { applyState() }
}
@@ -195,7 +204,7 @@ final class NativeAddMembersViewController: UITableViewController, UISearchResul
navigationItem.rightBarButtonItem?.title = busy ? "Adding…" : selected.isEmpty ? "Add" : "Add (\(selected.count))"
}
search.searchBar.isUserInteractionEnabled = !busy
tableView.reloadData()
applySnapshot()
let error = state["error"] as? String ?? ""
if error.isEmpty {
tableView.tableFooterView = nil
@@ -245,11 +254,55 @@ final class NativeAddMembersViewController: UITableViewController, UISearchResul
return "No matching people or agents."
}
private var shownUsers: [[String: Any]] { atLimit ? [] : users }
private func isStatus(_ indexPath: IndexPath) -> Bool {
indexPath.section == 1 && indexPath.row >= shownUsers.count
/// A row: a member by pubkey, or the status row after the results. A
/// member keeps its identity across sections, so a pick moves its row.
private enum Item: Hashable {
case member(String)
case status
}
private static let cellIdentifier = "member"
private var rows: UITableViewDiffableDataSource<Int, Item>!
/// What each shown row was last drawn from, to redraw only changed rows.
private var drawn: [Item: NSDictionary] = [:]
private func member(_ pubkey: String) -> [String: Any]? {
(selected + shownUsers).first { $0["pubkey"] as? String == pubkey }
}
private func drawing(_ item: Item) -> NSDictionary {
switch item {
case .member(let pubkey):
return ["user": member(pubkey) ?? [:], "busy": busy, "selected": selected.contains { $0["pubkey"] as? String == pubkey }]
case .status:
return ["text": status ?? "", "retry": loadError && !atLimit, "busy": busy]
}
}
/// Applies the state as a diff: unchanged rows keep their cells, changed
/// ones are redrawn in place, and a pick or removal animates its row
/// between the chosen and result sections.
private func applySnapshot() {
let chosen = selected.compactMap { $0["pubkey"] as? String }
var results: [Item] = shownUsers.compactMap { ($0["pubkey"] as? String).map(Item.member) }
if status != nil { results.append(.status) }
var snapshot = NSDiffableDataSourceSnapshot<Int, Item>()
snapshot.appendSections([0, 1])
snapshot.appendItems(chosen.map(Item.member), toSection: 0)
snapshot.appendItems(results, toSection: 1)
let previous = rows.snapshot()
let wasChosen = previous.numberOfSections > 0 ? previous.itemIdentifiers(inSection: 0) : []
let next = Dictionary(uniqueKeysWithValues: snapshot.itemIdentifiers.map { ($0, drawing($0)) })
let changed = snapshot.itemIdentifiers.filter { item in
drawn[item].map { $0 != next[item] } ?? false
}
snapshot.reconfigureItems(changed.filter(previous.itemIdentifiers.contains))
drawn = next
let animate = view.window != nil && wasChosen != chosen.map(Item.member)
&& !UIAccessibility.isReduceMotionEnabled
rows.apply(snapshot, animatingDifferences: animate)
}
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
// No section titles, and no grouped-table header bands: a small gap
// above the first shown card and between the two.
override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
@@ -262,79 +315,74 @@ final class NativeAddMembersViewController: UITableViewController, UISearchResul
override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { nil }
override func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { nil }
private static let cardGap: CGFloat = 8
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
section == 0 ? selected.count : shownUsers.count + (status == nil ? 0 : 1)
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
private func configure(_ cell: UITableViewCell, item: Item) {
cell.backgroundColor = rowColor
var content = cell.defaultContentConfiguration()
var avatarRequest: (String, String)?
var memberLabel: String?
if isStatus(indexPath) {
cell.accessoryType = .none
cell.selectionStyle = .default
cell.accessibilityTraits = .none
cell.isAccessibilityElement = false
cell.accessibilityLabel = nil
cell.accessibilityIdentifier = nil
cell.contentView.accessibilityElementsHidden = false
cell.isUserInteractionEnabled = !busy
var content = UIListContentConfiguration.subtitleCell()
guard case .member(let pubkey) = item, let user = member(pubkey) else {
content.text = status
content.textProperties.color = loadError && !atLimit ? Self.errorColor : .secondaryLabel
content.textProperties.numberOfLines = 0
cell.selectionStyle = loadError && !atLimit ? .default : .none
if loadError && !atLimit { cell.accessibilityTraits.insert(.button) }
} else {
let user = indexPath.section == 0 ? selected[indexPath.row] : shownUsers[indexPath.row]
let unavailable = user["unavailable"] as? Bool == true
content.text = user["name"] as? String
content.secondaryText = user["detail"] as? String
memberLabel = [content.text, content.secondaryText]
.compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", ")
content.textProperties.numberOfLines = 0
content.secondaryTextProperties.numberOfLines = 1
if let pubkey = user["pubkey"] as? String, let key = user["avatarKey"] as? String {
avatarRequest = (pubkey, key)
}
content.image = avatarRequest.flatMap { avatars[$0.1] } ?? fallbackAvatar(user)
content.imageProperties.maximumSize = CGSize(width: 40, height: 40)
content.imageProperties.reservedLayoutSize = CGSize(width: 40, height: 40)
cell.accessoryType = indexPath.section == 0 ? .checkmark : .none
if unavailable {
content.textProperties.color = .secondaryLabel
cell.selectionStyle = .none
cell.accessibilityTraits.insert(.notEnabled)
}
cell.accessibilityTraits.insert(.button)
if indexPath.section == 0 { cell.accessibilityTraits.insert(.selected) }
cell.contentConfiguration = content
return
}
let chosen = selected.contains { $0["pubkey"] as? String == pubkey }
let key = user["avatarKey"] as? String
content.text = user["name"] as? String
content.secondaryText = user["detail"] as? String
content.textProperties.numberOfLines = 0
content.secondaryTextProperties.numberOfLines = 1
content.image = key.flatMap { avatars[$0] } ?? fallbackAvatar(user)
content.imageProperties.maximumSize = CGSize(width: 40, height: 40)
content.imageProperties.reservedLayoutSize = CGSize(width: 40, height: 40)
cell.accessoryType = chosen ? .checkmark : .none
if user["unavailable"] as? Bool == true {
content.textProperties.color = .secondaryLabel
cell.selectionStyle = .none
cell.accessibilityTraits.insert(.notEnabled)
}
cell.accessibilityTraits.insert(.button)
if chosen { cell.accessibilityTraits.insert(.selected) }
cell.contentConfiguration = content
if let memberLabel {
cell.isAccessibilityElement = true
cell.accessibilityLabel = memberLabel
cell.isAccessibilityElement = true
cell.accessibilityLabel = [content.text, content.secondaryText]
.compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", ")
cell.contentView.accessibilityElementsHidden = true
guard let key else { return }
cell.accessibilityIdentifier = key
guard avatars[key] == nil else { return }
loadAvatar(pubkey, key) { [weak self, weak cell] data in
guard let data, let image = UIImage(data: data) else { return }
let avatar = image.withRenderingMode(.alwaysOriginal)
self?.avatars[key] = avatar
guard let cell, cell.accessibilityIdentifier == key,
var configuration = cell.contentConfiguration as? UIListContentConfiguration else { return }
configuration.image = avatar
let label = cell.accessibilityLabel
cell.contentConfiguration = configuration
cell.accessibilityLabel = label
cell.contentView.accessibilityElementsHidden = true
}
if let (_, key) = avatarRequest { cell.accessibilityIdentifier = key }
if let (pubkey, key) = avatarRequest, avatars[key] == nil {
loadAvatar(pubkey, key) { [weak self, weak cell] data in
guard let data, let image = UIImage(data: data) else { return }
let avatar = image.withRenderingMode(.alwaysOriginal)
self?.avatars[key] = avatar
guard let cell, cell.accessibilityIdentifier == key,
var configuration = cell.contentConfiguration as? UIListContentConfiguration else { return }
configuration.image = avatar
let label = cell.accessibilityLabel
cell.contentConfiguration = configuration
cell.accessibilityLabel = label
cell.contentView.accessibilityElementsHidden = true
}
}
cell.isUserInteractionEnabled = !busy
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard !busy else { return }
if isStatus(indexPath) {
guard !busy, let item = rows.itemIdentifier(for: indexPath) else { return }
guard case .member(let pubkey) = item, let user = member(pubkey) else {
if loadError && !atLimit { send("retry", [:]) }
return
}
let user = indexPath.section == 0 ? selected[indexPath.row] : shownUsers[indexPath.row]
if user["unavailable"] as? Bool == true { return }
guard let pubkey = user["pubkey"] as? String else { return }
send("toggle", ["pubkey": pubkey])
// Where asked (New DM), a pick ends its query, as an inserted mention
// does, so the next name starts fresh.