Animated Updates
Update your table data with smooth, scroll-preserving animations.
When your data changes — a new row added, a value updated, items reordered — you want the table to reflect those changes smoothly. Not a jarring full reload that flashes the screen and loses the user's scroll position, but a graceful animation where only the changed parts update.
SwiftDataTables handles this automatically through diffing. You give it the new data, it figures out what changed, and animates the minimal set of updates.
What Users See
When you update data with animations enabled:
- New rows slide in from the edge, pushing other rows aside
- Deleted rows fade out and collapse, with remaining rows sliding to fill the gap
- Moved rows glide to their new positions
- Changed cells update their content in place — no row animation, just the value changes
- Scroll position stays exactly where it was — users don't lose their place
The result feels responsive and alive, not like a page refresh.
Basic Usage
// Your data model
struct Task: Identifiable {
let id: UUID
var title: String
var isComplete: Bool
}
// Initial load
var tasks: [Task] = [...]
let dataTable = SwiftDataTable(data: tasks, columns: columns)
// Later: update the data
tasks.append(Task(id: UUID(), title: "New task", isComplete: false))
dataTable.setData(tasks, animatingDifferences: true)That's it. The table compares the old and new arrays, finds the new task, and animates it in.
How Diffing Works
The diffing algorithm runs in three phases:
Phase 1: Identity Comparison
Every row has an identity — the id property from Identifiable. The algorithm compares IDs between the old and new data to classify each row:
| Old Data | New Data | Classification |
|---|---|---|
| ID exists | ID exists, same position | Unchanged (check content) |
| ID exists | ID exists, different position | Moved |
| ID exists | ID missing | Deleted |
| ID missing | ID exists | Inserted |
Phase 2: Cell-Level Comparison
For rows that exist in both snapshots (same ID), the algorithm compares each column value. Only cells where the value actually changed get reloaded.
// Before: Task { id: 1, title: "Buy milk", isComplete: false }
// After: Task { id: 1, title: "Buy milk", isComplete: true }
// Result: Only the "isComplete" cell reloads
// The "title" cell is untouched — no flicker, no reconfigurationThis is especially noticeable in wide tables. Updating one column doesn't cause the entire row to flash.
Phase 3: Batch Animation
All changes are collected into a single batch update. UICollectionView animates insertions, deletions, moves, and reloads together in one smooth transaction.
The Identifiable Requirement
For diffing to work, the table needs to know which rows are "the same" between updates. This is what Identifiable provides — a stable identity that persists across data changes.
struct Employee: Identifiable {
let id: String // Database ID, UUID, or any unique value
var name: String
var department: String
}IDs must be stable If an employee's id changes between updates, the table treats it as a deletion + insertion (old row removed, new row added). Use database IDs, UUIDs, or other values that don't change when content changes.
Common ID Mistakes
- Using array index as ID — IDs change when items are reordered, causing unnecessary animations
- Using content hash as ID — Any content change looks like a delete + insert instead of an update
- Generating new UUIDs on fetch — Each API response creates "new" items, nothing animates as an update
Raw Data API
If you're using the array-based API instead of typed models, you can still get animated diffing by providing explicit row identifiers:
let data: [[DataTableValueType]] = [
[.string("Alice"), .int(28)],
[.string("Bob"), .int(34)]
]
let headers = ["Name", "Age"]
// Without identifiers: content-based identity (rows with identical content are "same")
dataTable.setData(data, headerTitles: headers, animatingDifferences: true)
// With explicit identifiers: recommended for database records
let ids = ["emp-001", "emp-002"]
dataTable.setData(data, headerTitles: headers, rowIdentifiers: ids, animatingDifferences: true)The Completion Handler
Need to know when the animation finishes? Use the completion parameter:
dataTable.setData(items, animatingDifferences: true) {
// Animation complete
self.refreshControl.endRefreshing()
self.activityIndicator.stopAnimating()
}Scroll Position Preservation
Both animated and non-animated updates preserve scroll position. If the user is viewing row 500 and you update the data, they're still viewing row 500 (or its replacement) after the update.
This works through scroll anchoring:
- Insertions above viewport — Content offset increases to compensate, keeping visible content in place
- Deletions above viewport — Content offset decreases
- Height changes — If a row above the viewport gets taller or shorter, offset adjusts
- Anchor row deleted — Falls back to the nearest surviving row
Common Patterns
Pull to Refresh
@objc func handleRefresh(_ refreshControl: UIRefreshControl) {
Task {
let newItems = await api.fetchItems()
await MainActor.run {
items = newItems
dataTable.setData(items, animatingDifferences: true) {
refreshControl.endRefreshing()
}
}
}
}Optimistic Updates
Update the UI immediately, then sync with the server. Roll back if the request fails.
func deleteItem(_ item: Item) {
// Immediately remove from UI
items.removeAll { $0.id == item.id }
dataTable.setData(items, animatingDifferences: true)
// Then sync with server
Task {
do {
try await api.delete(item)
} catch {
// Rollback on failure
items.append(item)
dataTable.setData(items, animatingDifferences: true)
showError(error)
}
}
}Real-Time Updates
Streaming data from WebSockets or other real-time sources:
func handleWebSocketMessage(_ message: StockUpdate) {
if let index = stocks.firstIndex(where: { $0.id == message.stockId }) {
stocks[index].price = message.newPrice
stocks[index].change = message.change
}
// Diffing finds the changed stock and updates only those cells
dataTable.setData(stocks, animatingDifferences: true)
}When to Skip Animation
Animation isn't always appropriate:
- Initial load — No previous data to diff against. Animation is skipped automatically.
- Bulk imports — Loading thousands of rows at once. Diffing is expensive; use
animatingDifferences: false. - Complete data replacement — If >50% of rows change, animation can look chaotic. Consider skipping it.
- Background refresh — If the user isn't looking at the table, animation is wasted work.
// Skip animation for large changes
let skipAnimation = newItems.count > 1000 ||
abs(newItems.count - items.count) > items.count / 2
dataTable.setData(newItems, animatingDifferences: !skipAnimation)Troubleshooting
Animations not happening
- Check Identifiable conformance — Without stable IDs, every update looks like a full replacement
- Verify IDs are stable — Print IDs before and after to confirm they match
- Ensure animatingDifferences is true — Easy to accidentally pass
false
Too many animations (everything moves)
- IDs are changing — If IDs regenerate on each fetch, every row is "new"
- Sorting changed — If sort order changes, rows move to new positions. This is correct behavior.
- Array index as ID — Inserting at the beginning makes all IDs shift
Performance issues with large datasets
- Diffing is O(n) — 100K rows means 100K comparisons. Consider
animatingDifferences: falsefor bulk updates. - Batch your changes — Make all model changes first, then call
setDataonce - Profile with Instruments — If still slow, check if column width recalculation is the bottleneck