mirror of
https://github.com/block/buzz.git
synced 2026-11-11 11:51:13 +00:00
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:
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user