I make Swept, a free iPhone photo cleaner with ads; this is how its Trash works. Excerpts are from the real code, trimmed, with a few comments shortened.
A swipe-to-clean app has a built-in mismatch. A swipe takes a fraction of a second and people do hundreds in one sitting. Deleting from the photo library is heavier, and iOS guards it. Apple's documentation for performChanges(_:completionHandler:) says:
For each call to this method, iOS shows an alert asking the user for permission to edit the contents of the photo library. If your app needs to submit several changes at once, combine them into a single change block.
So "delete on swipe" was never an option: that's a system alert per card. Swept does the opposite. A left swipe writes one row to a local SwiftData store and nothing else. The photo library only changes when the user opens Trash and empties it, all in one go.
A review is a row
The whole model is one class:
enum ReviewStatus: String, Codable {
case kept
case trashed
}
/// The presence of a row means the photo is "reviewed".
@Model
final class PhotoReview {
/// PHAsset.localIdentifier, unique per asset.
@Attribute(.unique) var assetIdentifier: String
var statusRaw: String
var reviewedAt: Date
var status: ReviewStatus {
get { ReviewStatus(rawValue: statusRaw) ?? .kept }
set { statusRaw = newValue.rawValue }
}
}
That gives three states for free:
- No row: never reviewed. "New only" decks show exactly these.
-
.keptrow: seen and kept, so it stays out of "New only" decks. -
.trashedrow: staged for deletion. Still in the library, still using space.
Restoring from Trash deletes the row, so the photo counts as unreviewed again; undo in the deck does the same. Swiping up to the Private Vault also writes a .trashed row for the original, because the vault keeps its own copy. And an unreadable raw value reads as .kept, the safe direction.
The store is a thin wrapper over a ModelContext. The Trash screen is just a query:
/// All photos marked as trashed, most recently reviewed first.
func trashedReviews() -> [PhotoReview] {
let trashedRaw = ReviewStatus.trashed.rawValue
let descriptor = FetchDescriptor<PhotoReview>(
predicate: #Predicate { $0.statusRaw == trashedRaw },
sortBy: [SortDescriptor(\.reviewedAt, order: .reverse)]
)
return (try? context.fetch(descriptor)) ?? []
}
func mark(identifier: String, status: ReviewStatus) {
if let existing = review(for: identifier) {
existing.status = status
existing.reviewedAt = .now
} else {
context.insert(PhotoReview(assetIdentifier: identifier, status: status))
}
save()
}
The status is stored as its raw String and pulled into a local constant before the #Predicate, so the predicate is a plain string comparison against a captured value. mark is an upsert: re-reviewing a kept photo flips its row to trashed instead of adding a second one, with @Attribute(.unique) backing that up.
A left swipe calls mark(identifier:status:) with .trashed. That's the whole cost of a swipe: no PhotoKit write, no prompt, and trivially undoable.
Emptying Trash is one deleteAssets call
Here is the function that actually removes assets from the library:
/// Permanently deletes the given assets. Triggers the system confirmation alert.
@discardableResult
func deleteAssets(identifiers: [String]) async -> Bool {
let fetch = PHAsset.fetchAssets(withLocalIdentifiers: identifiers, options: nil)
// Nothing matched: the assets are already gone, so the requested end
// state is achieved. Return true so Empty Trash can clear orphaned rows.
guard fetch.count > 0 else { return true }
let success = await withCheckedContinuation { continuation in
PHPhotoLibrary.shared().performChanges {
PHAssetChangeRequest.deleteAssets(fetch)
} completionHandler: { success, _ in
continuation.resume(returning: success)
}
}
if success {
for id in identifiers { assetCache.removeValue(forKey: id) }
}
return success
}
Empty Trash passes every identifier on screen (respecting the Photos / Videos filter), so 3 items or 300 is one change block, one deleteAssets call and one system alert. The Trash view awaits the Bool. On true it removes those review rows and the tiles disappear. On false the rows and items stay. Declining the iOS alert ends up there, so "no" is a quiet no-op, not an error state.
The swipe deck, the duplicate finder, Find Similar and Sort by Size never call this. Their "delete" is a .trashed row. The other callers are explicit, one-off requests, such as Shrink Videos (Pro), which saves a smaller copy and then deletes the original behind the same system alert.
Two safety nets I didn't have to build
- The iOS alert. Swept can't skip it, and I wouldn't want to. Settings > Cleaning > "Confirm before delete" (on by default) adds Swept's own "Permanently delete N items?" alert in front of it, for people who want to see a count first.
- Recently Deleted. Assets deleted this way land in the Photos app's Recently Deleted album, which Apple keeps for 30 days. A mistake after Empty Trash is still recoverable from Photos.
There's a catch with the second one. Apple's iCloud storage article says that if you exceed your iCloud storage limit, your device immediately removes any photos and videos you delete and they won't be in Recently Deleted. So Recently Deleted isn't the main undo. The in-app Trash is, and the user decides when it empties.
Assets that vanish in the meantime
Swept's Trash has no time limit, so weeks can pass between a swipe and Empty Trash. Meanwhile the user might delete the photo in Photos, or iCloud Photos might remove it after a delete on another device. A row pointing at nothing would show as a blank tile and inflate the count.
So the Trash screen reconciles on every appearance:
private func refresh() {
let trashed = store.trashedReviews().map(\.assetIdentifier)
// One fetch yields existence AND creation dates: the dates seed the sort
// so the grid renders already ordered (no post-load rearrange).
let (existing, seededDates) = library.existingAssets(from: trashed)
// Clean up rows whose assets no longer exist.
let stale = trashed.filter { !existing.contains($0) }
if !stale.isEmpty { store.removeReviews(identifiers: stale) }
identifiers = trashed.filter { existing.contains($0) }
dates = seededDates
recomputeDisplayed()
loadMetadata(for: identifiers)
}
existingAssets(from:) is one PHAsset.fetchAssets(withLocalIdentifiers:options:) call, enumerated once, collecting the identifiers that still exist and their creationDate. Creation date is cheap, so the grid renders already sorted newest first. File sizes aren't, so they load afterwards on a detached task through a nonisolated static function; only the Largest / Smallest sorts wait for them.
The guard fetch.count > 0 else { return true } in deleteAssets covers the same problem at the last moment. If everything vanished between loading and the tap, "already gone" counts as success, so Empty Trash can't fail forever. If only some vanished, the fetch holds just the survivors, PhotoKit deletes those, and all the rows are cleared.
The "One last step" sheet
The model is honest, but a left swipe still looks like a delete. Someone who swipes through 200 photos, backs out and checks Settings sees no change, and would fairly think the app did nothing. People who finish a deck get a session-complete screen with a "One last step" card and a "Review & empty Trash (N)" button. People who leave halfway never see it.
So the back button checks three things: the deck isn't finished, this session staged something, and the user has never seen the reminder. If all hold, it presents a medium-detent sheet titled "One last step": the N items are waiting in the Trash and stay in the photo gallery, still taking up space, until it's emptied. One button goes straight to the Trash, the other exits.
It's gated by an @AppStorage flag, so it appears once per install. A comment in the code calls it "education, not a gate": after the first time, the results screen and the Trash count on Home repeat the lesson, and a sheet on every exit would only nag.
Trade-offs I accepted
- A Trash that never empties itself can grow large. The button shows the count and total size ("Empty Trash (N) ยท size") and there's a Largest first sort, so at least it's visible.
- Two steps (Empty Trash, then Recently Deleted) is more than people expect from a "cleaner". The sheet and card exist because of that; the alternative is an iOS alert per card.
If you want to see it on a phone: Swept on the App Store. Questions about the PhotoKit side are welcome in the comments.
Top comments (0)