Trouble with contextMenu previewing high resolution images #174664
Replies: 1 comment 1 reply
-
This is a classic "context menu preview + Photos" lifecycle trap. What you're seeing is SwiftUI's strange behaviour on Below is a battle-tested approach that (a) requests the full-size only for the preview, (b) guarantees exactly one request per preview, (c) cancels on teardown, and (d) avoids Photos caching runaway growth. What's going on
If your full-size request lives in the cell or a parent, you'll accumulate Design that stays leanRule of thumb: Make the preview responsible for its own image lifecycle. Key tricks:
Minimal working patternimport SwiftUI
import Photos
import PhotosUI
struct AssetCell: View {
let asset: PHAsset
private let manager = PHCachingImageManager.default()
var body: some View {
// Your thumbnail (fast, tiny memory)
ThumbnailView(asset: asset, manager: manager)
.contextMenu {
Button("Do something") { /* menu actions */ }
} preview: {
// Put the heavy request *here*, scoped to the preview only.
FullSizePreview(asset: asset, manager: manager)
// Ensure SwiftUI tears down between different assets:
.id(asset.localIdentifier)
}
}
}
struct FullSizePreview: View {
let asset: PHAsset
let manager: PHCachingImageManager
@StateObject private var vm = FullSizePreviewVM()
var body: some View {
ZStack {
if let image = vm.image {
Image(uiImage: image).resizable().scaledToFit()
} else {
ProgressView()
}
}
.task(id: asset.localIdentifier) {
await vm.loadFullSize(asset: asset, manager: manager)
}
.onDisappear {
vm.teardown(manager: manager)
}
}
}
@MainActor
final class FullSizePreviewVM: ObservableObject {
@Published var image: UIImage? = nil
private var requestID: PHImageRequestID?
private var isLoaded = false
func loadFullSize(asset: PHAsset, manager: PHCachingImageManager) async {
guard !isLoaded else { return }
isLoaded = true
// Target ~1000x1000 to avoid huge decodes. Use asset pixel ratio for crispness.
let target = CGSize(width: 1000, height: 1000)
let options = PHImageRequestOptions()
options.isNetworkAccessAllowed = true
options.deliveryMode = .highQualityFormat
options.resizeMode = .fast
options.isSynchronous = false
// Optional: pre-cache just this one, then stop later.
manager.startCachingImages(
for: [asset],
targetSize: target,
contentMode: .aspectFit,
options: options
)
self.requestID = manager.requestImage(
for: asset,
targetSize: target,
contentMode: .aspectFit,
options: options
) { [weak self] img, info in
guard let self else { return }
// Avoid holding onto large images if the request was degraded/cancelled
let cancelled = (info?[PHImageCancelledKey] as? Bool) == true
let degraded = (info?[PHImageResultIsDegradedKey] as? Bool) == true
if cancelled { return }
if let img, !degraded {
// Encourage early release of intermediates
autoreleasepool {
self.image = img
}
}
}
}
func teardown(manager: PHCachingImageManager) {
if let id = requestID {
manager.cancelImageRequest(id)
requestID = nil
}
// Stop caching what we started for this preview
// (We don't hold a reference to the asset here, so the caller can do it;
// or pass the asset in teardown if you prefer.)
image = nil
}
deinit {
// Belt-and-suspenders; if you want to cancel here, you need
// a reference to the manager. Common pattern: store a weak manager.
image = nil
}
}
// Fast thumbnail view (kept tiny)
struct ThumbnailView: View {
let asset: PHAsset
let manager: PHCachingImageManager
@State private var thumb: UIImage?
var body: some View {
Group {
if let thumb {
Image(uiImage: thumb).resizable().scaledToFill()
} else {
Color.secondary.opacity(0.1)
.overlay { ProgressView().controlSize(.mini) }
}
}
.frame(width: 100, height: 100)
.clipped()
.task(id: asset.localIdentifier) {
await requestThumb()
}
}
@MainActor
private func requestThumb() async {
let size = CGSize(width: 200, height: 200)
let opts = PHImageRequestOptions()
opts.deliveryMode = .fastFormat
opts.resizeMode = .fast
opts.isSynchronous = false
_ = manager.requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: opts) { img, _ in
if let img {
autoreleasepool { self.thumb = img }
}
}
}
} Additional guardrails
Answer to "where do I request the full-size image?"Inside the preview view that you pass to contextMenu's preview: closure, not in the cell or parent list. That localizes the lifetime to the menu interaction, guarantees cancellation on dismiss, and prevents hidden re-entrancy from onAppear in the grid. |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
Body
When using a contextMenu in SwiftUI to show a preview of a PHAsset’s full-size image via PHCachingImageManager.requestImage(), memory usage increases with each image preview interaction. The memory is not released, leading to eventual app crash due to memory exhaustion.
The thumbnail loads and behaves as expected, but each call to fetch the full-size image (1000x1000) for the contextMenu preview does not release memory, even after cancelImageRequest() is called and fullSizePreviewImage is set to nil.
The issue seems to stem from the contextMenu lifecycle behavior, it triggers .onAppear unexpectedly, and the full-size image is repeatedly fetched without releasing the previously loaded images.
The question is, where do I request to the get the full-size image to show it in the context menu preview?
https://github.com/luisazmouz/contextMenu
Guidelines
Beta Was this translation helpful? Give feedback.
All reactions