GitHub

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 DataNew DataClassification
ID existsID exists, same positionUnchanged (check content)
ID existsID exists, different positionMoved
ID existsID missingDeleted
ID missingID existsInserted

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 reconfiguration

This 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: false for bulk updates.
  • Batch your changes — Make all model changes first, then call setData once
  • Profile with Instruments — If still slow, check if column width recalculation is the bottleneck