SwiftUI LazyVGrid(MacOS) and AsyncImage loads again when scrolling away and back [duplicate] - swift

I'm new to SwiftUI and was looking how to download images from a URL. I've found out that in iOS15 you can use AsyncImage to handle all the phases of an Image. The code looks like this.
AsyncImage(url: URL(string: urlString)) { phase in
switch phase {
case .success(let image):
image
.someModifers
case .empty:
Image(systemName: "Placeholder Image")
.someModifers
case .failure(_):
Image(systemName: "Error Image")
.someModifers
#unknown default:
Image(systemName: "Placeholder Image")
.someModifers
}
}
I would launch my app and every time I would scroll up & down on my List, it would download the images again. So how would I be able to add a cache. I was trying to add a cache the way I did in Swift. Something like this.
struct DummyStruct {
var imageCache = NSCache<NSString, UIImage>()
func downloadImageFromURLString(_ urlString: String) {
guard let url = URL(string: urlString) else { return }
URLSession.shared.dataTask(with: url) { data, response, error in
if let _ = error {
fatalError()
}
guard let data = data, let image = UIImage(data: data) else { return }
imageCache.setObject(image, forKey: NSString(string: urlString))
}
.resume()
}
}
But it didn't go to good. So I was wondering is there a way to add caching to AsyncImage? Thanks would appreciate any help.

I had the same problem as you. I solved it by writing a CachedAsyncImage that kept the same API as AsyncImage, so that they could be interchanged easily, also in view of future native cache support in AsyncImage.
I made a Swift Package to share it.
CachedAsyncImage has the exact same API and behavior as AsyncImage, so you just have to change this:
AsyncImage(url: logoURL)
to this:
CachedAsyncImage(url: logoURL)
In addition to AsyncImage initializers, you have the possibilities to specify the cache you want to use (by default URLCache.shared is used):
CachedAsyncImage(url: logoURL, urlCache: .imageCache)
// URLCache+imageCache.swift
extension URLCache {
static let imageCache = URLCache(memoryCapacity: 512*1000*1000, diskCapacity: 10*1000*1000*1000)
}
Remember when setting the cache the response (in this case our image) must be no larger than about 5% of the disk cache (See this discussion).
Here is the repo.

Hope this can help others.
I found this great video which talks about using the code below to build a async image cache function for your own use.
import SwiftUI
struct CacheAsyncImage<Content>: View where Content: View{
private let url: URL
private let scale: CGFloat
private let transaction: Transaction
private let content: (AsyncImagePhase) -> Content
init(
url: URL,
scale: CGFloat = 1.0,
transaction: Transaction = Transaction(),
#ViewBuilder content: #escaping (AsyncImagePhase) -> Content
){
self.url = url
self.scale = scale
self.transaction = transaction
self.content = content
}
var body: some View{
if let cached = ImageCache[url]{
let _ = print("cached: \(url.absoluteString)")
content(.success(cached))
}else{
let _ = print("request: \(url.absoluteString)")
AsyncImage(
url: url,
scale: scale,
transaction: transaction
){phase in
cacheAndRender(phase: phase)
}
}
}
func cacheAndRender(phase: AsyncImagePhase) -> some View{
if case .success (let image) = phase {
ImageCache[url] = image
}
return content(phase)
}
}
fileprivate class ImageCache{
static private var cache: [URL: Image] = [:]
static subscript(url: URL) -> Image?{
get{
ImageCache.cache[url]
}
set{
ImageCache.cache[url] = newValue
}
}
}

AsyncImage uses default URLCache under the hood. The simplest way to manage the cache is to change the properties of the default URLCache
URLCache.shared.memoryCapacity = 50_000_000 // ~50 MB memory space
URLCache.shared.diskCapacity = 1_000_000_000 // ~1GB disk cache space

User like this
ImageView(url: URL(string: "https://wallpaperset.com/w/full/d/2/b/115638.jpg"))
.frame(width: 300, height: 300)
.cornerRadius(20)
ImageView(url: URL(string: "https://ba")) {
// Placeholder
Text("⚠️")
.font(.system(size: 120))
}
.frame(width: 300, height: 300)
.cornerRadius(20)
ImageView.swift
import SwiftUI
struct ImageView<Placeholder>: View where Placeholder: View {
// MARK: - Value
// MARK: Private
#State private var image: Image? = nil
#State private var task: Task<(), Never>? = nil
#State private var isProgressing = false
private let url: URL?
private let placeholder: () -> Placeholder?
// MARK: - Initializer
init(url: URL?, #ViewBuilder placeholder: #escaping () -> Placeholder) {
self.url = url
self.placeholder = placeholder
}
init(url: URL?) where Placeholder == Color {
self.init(url: url, placeholder: { Color("neutral9") })
}
// MARK: - View
// MARK: Public
var body: some View {
GeometryReader { proxy in
ZStack {
placholderView
imageView
progressView
}
.frame(width: proxy.size.width, height: proxy.size.height)
.task {
task?.cancel()
task = Task.detached(priority: .background) {
await MainActor.run { isProgressing = true }
do {
let image = try await ImageManager.shared.download(url: url)
await MainActor.run {
isProgressing = false
self.image = image
}
} catch {
await MainActor.run { isProgressing = false }
}
}
}
.onDisappear {
task?.cancel()
}
}
}
// MARK: Private
#ViewBuilder
private var imageView: some View {
if let image = image {
image
.resizable()
.scaledToFill()
}
}
#ViewBuilder
private var placholderView: some View {
if !isProgressing, image == nil {
placeholder()
}
}
#ViewBuilder
private var progressView: some View {
if isProgressing {
ProgressView()
.progressViewStyle(.circular)
}
}
}
#if DEBUG
struct ImageView_Previews: PreviewProvider {
static var previews: some View {
let view = VStack {
ImageView(url: URL(string: "https://wallpaperset.com/w/full/d/2/b/115638.jpg"))
.frame(width: 300, height: 300)
.cornerRadius(20)
ImageView(url: URL(string: "https://wallpaperset.com/w/full/d/2/b/115638")) {
Text("⚠️")
.font(.system(size: 120))
}
.frame(width: 300, height: 300)
.cornerRadius(20)
}
view
.previewDevice("iPhone 11 Pro")
.preferredColorScheme(.light)
}
}
#endif
ImageManager.swift
import SwiftUI
import Combine
import Photos
final class ImageManager {
// MARK: - Singleton
static let shared = ImageManager()
// MARK: - Value
// MARK: Private
private lazy var imageCache = NSCache<NSString, UIImage>()
private var loadTasks = [PHAsset: PHImageRequestID]()
private let queue = DispatchQueue(label: "ImageDataManagerQueue")
private lazy var imageManager: PHCachingImageManager = {
let imageManager = PHCachingImageManager()
imageManager.allowsCachingHighQualityImages = true
return imageManager
}()
private lazy var downloadSession: URLSession = {
let configuration = URLSessionConfiguration.default
configuration.httpMaximumConnectionsPerHost = 90
configuration.timeoutIntervalForRequest = 90
configuration.timeoutIntervalForResource = 90
return URLSession(configuration: configuration)
}()
// MARK: - Initializer
private init() {}
// MARK: - Function
// MARK: Public
func download(url: URL?) async throws -> Image {
guard let url = url else { throw URLError(.badURL) }
if let cachedImage = imageCache.object(forKey: url.absoluteString as NSString) {
return Image(uiImage: cachedImage)
}
let data = (try await downloadSession.data(from: url)).0
guard let image = UIImage(data: data) else { throw URLError(.badServerResponse) }
queue.async { self.imageCache.setObject(image, forKey: url.absoluteString as NSString) }
return Image(uiImage: image)
}
}

Maybe later to the party, but I came up to this exact problem regarding poor performances of AsyncImage when used in conjunction with ScrollView / LazyVStack layouts.
According to this thread, seams that the problem is in someway due to Apple's current implementation and sometime in the future it will be solved.
I think that the most future-proof approach we can use is something similar to the response from Ryan Fung but, unfortunately, it uses an old syntax and miss the overloaded init (with and without placeholder).
I extended the solution, covering the missing cases on this GitHub's Gist. You can use it like current AsyncImage implementation, so that when it will support cache consistently you can swap it out.

Related

How to call API again after change was made?

So I want to search books from google books api, but only through url query, how can I call API again when I enter the text in the search bar? How to reload the call?
I tried also with textfield onSumbit method, but nothing work.
I just want to insert value of textSearch to network.searchText and that network.searchText to insert into q=
here is my code of ContentView:
//
// ContentView.swift
// BookApi
//
// Created by Luka Šalipur on 7.6.22..
//
import SwiftUI
struct URLImage: View{
var urlString: String
#State var data: Data?
var body: some View{
if let data = data, let uiimage = UIImage(data:data) {
Image(uiImage: uiimage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width:80, height:120)
.background(Color.gray)
} else {
Image(systemName: "book").onAppear {
fetch()
}
}
}
private func fetch(){
guard let url = URL(string: urlString) else {
return
}
let task = URLSession.shared.dataTask(with:url) { data, _, error in
self.data = data
}
task.resume()
}
}
// ContentView
struct ContentView: View {
#ObservedObject var network = Network()
#State var textSearch:String = "knjiga"
#State private var shouldReload: Bool = false
func context(){
network.searchText = self.textSearch
print(network.searchText)
}
var body: some View {
NavigationView{
List{
ForEach(network.book, id:\.self){ item in
NavigationLink{
Webview(url: URL(string: "\(item.volumeInfo.previewLink)")!)
} label: {
HStack{
URLImage(urlString: item.volumeInfo.imageLinks.thumbnail)
Text("\(item.volumeInfo.title)")
}
}
}
}
.onAppear{
context()
}
.onChange(of: textSearch, perform: { value in
self.shouldReload.toggle()
})
.searchable(text: $textSearch)
.navigationTitle("Books")
.task{
await network.loadData()
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
And here is my API(network) call:
//
// Network.swift
// BookApi
//
// Created by Luka Šalipur on 7.6.22..
//
import Foundation
import SwiftUI
class Network: ObservableObject{
#Published var book = [Items]()
var searchText: String = "watermelon" {
willSet(newValue) {
print(newValue)
}
}
func loadData() async {
guard let url = URL(string: "https://www.googleapis.com/books/v1/volumes?q=\(searchText)&key=API_KEY_PRIVATE") else {
return
}
do {
let (data, _) = try await URLSession.shared.data(from: url)
if let decodedResponse = try? JSONDecoder().decode(Books.self, from: data) {
book = decodedResponse.items
}
} catch {
print("There is an error")
}
}
}
This is a perfect candidate for the Combine framework.
In Network create a publisher which removes duplicates, debounces the input for 0.3 seconds, builds the URL, loads the data and decodes it.
I don't have your types, probably there are many errors. But this is a quite efficient way for dynamic searching. By the way your naming with regard to singular and plural form is pretty confusing.
import Combine
import SwiftUI
class Network: ObservableObject {
#Published var book = [Items]()
#Published var query = ""
private var subscriptions = Set<AnyCancellable>()
init() {
searchPublisher
.sink { completion in
print(completion) // show the error to the user
} receiveValue: { [weak.self] books in
self?.book = books.items
}
.store(in: &subscriptions)
}
var searchPublisher : AnyPublisher<Books,Error> {
return $query
.removeDuplicates()
.debounce(for: 0.3, scheduler: RunLoop.main)
.compactMap{ query -> URL? in
guard !query.isEmpty else { return nil }
guard let url = URL(string: "https://www.googleapis.com/books/v1/volumes?q=\(query)&key=API_KEY_PRIVATE") else {
return nil
}
return url
}
.flatMap { url -> AnyPublisher<Data, URLError> in
return URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.eraseToAnyPublisher()
}
.decode(type: Books.self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
In the view create the view model (must be #StateObject!)
#StateObject var network = Network()
and bind searchable to query in network
.searchable(text: $network.query)
The view is updated when the data is available in network.book
The .task modifier ist not needed
There is another version of task that runs again when a value changes task(id:priority:_:). If a task is still running when the param changes it will be cancelled and restarted automatically. In your case use it as follows:
.task(id: textSearch) { newValue in
books = await getBooks(newValue)
}
Now we have async/await and task there is no need for an ObservableObject anymore.

How to load next Image while user is looking at first image in the TabView pages carousel with AsyncImageView In SwiftUI?

I have an images carousel that fetching heavy images from a few URLs and displaying asynchronously when user is going to the next page. And the problem is that user need to see a waiting wheel and wait to see the next image.
So the ideal case would be to eliminate the waiting wheel by loading the next image while user still seeing the fist image and I don't know how to do it properly.
If you know the solution please let me know, I will mean a lot to me.
Here's the code:
struct ContentView: View {
#State private var selectedImageToSee: Int = 0
private let imagesUrl: [String] = [
"https://images.unsplash.com/photo-1633125195723-04860dde4545?crop=entropy&cs=tinysrgb&fit=crop&fm=jpg&h=4032&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTYzNDIyMDUyMQ&ixlib=rb-1.2.1&q=80&w=3024",
"https://images.unsplash.com/photo-1633090332452-532d6b39422a?crop=entropy&cs=tinysrgb&fit=crop&fm=jpg&h=4032&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTYzNDIyMDI1Ng&ixlib=rb-1.2.1&q=80&w=3024",
"https://images.unsplash.com/photo-1591667954789-c25d5affec59?crop=entropy&cs=tinysrgb&fit=crop&fm=jpg&h=4032&ixid=MnwxfDB8MXxyYW5kb218MHx8bmF0dXJlLGNhcnN8fHx8fHwxNjM0MjIwNzU1&ixlib=rb-1.2.1&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=3024",
"https://images.unsplash.com/photo-1580316576539-aee1337e80e8?crop=entropy&cs=tinysrgb&fit=crop&fm=jpg&h=4032&ixid=MnwxfDB8MXxyYW5kb218MHx8bmF0dXJlLGNhcnN8fHx8fHwxNjM0MjIwNzk5&ixlib=rb-1.2.1&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=3024",
"https://images.unsplash.com/photo-1484976063837-eab657a7aca7?crop=entropy&cs=tinysrgb&fit=crop&fm=jpg&h=4032&ixid=MnwxfDB8MXxyYW5kb218MHx8bmF0dXJlLGNhcnN8fHx8fHwxNjM0MjIwNjky&ixlib=rb-1.2.1&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=3024"
]
var body: some View {
ImagesCaruselView(selectedImageToSee: $selectedImageToSee, urls: imagesUrl)
}
}
struct ImagesCaruselView: View {
#Binding var selectedImageToSee: Int
let urls: [String]
var body: some View {
TabView(selection: $selectedImageToSee) {
ForEach(0..<urls.count, id: \.self) { url in
AsyncImageView(
url: URL(string: urls[url])!,
placeholder: { ProgressView().scaleEffect(1.5).progressViewStyle(CircularProgressViewStyle(tint: .gray)) },
image: { Image(uiImage: $0) }
)
.padding()
}
}
.tabViewStyle(.page)
.indexViewStyle(.page(backgroundDisplayMode: .always))
}
}
struct AsyncImageView<Placeholder: View>: View {
#StateObject private var loader: ImageLoader
private let placeholder: Placeholder
private let image: (UIImage) -> Image
init(
url: URL,
#ViewBuilder placeholder: () -> Placeholder,
#ViewBuilder image: #escaping (UIImage) -> Image = Image.init(uiImage:)
) {
self.placeholder = placeholder()
self.image = image
_loader = StateObject(wrappedValue: ImageLoader(url: url, cache: Environment(\.imageCache).wrappedValue))
}
var body: some View {
content
.onAppear(perform: loader.load)
}
private var content: some View {
Group {
if loader.image != nil {
image(loader.image!)
.resizable()
.aspectRatio(contentMode: .fit)
} else {
placeholder
}
}
}
}
protocol ImageCache {
subscript(_ url: URL) -> UIImage? { get set }
}
struct TemporaryImageCache: ImageCache {
private let cache: NSCache<NSURL, UIImage> = {
let cache = NSCache<NSURL, UIImage>()
cache.countLimit = 100 // 100 items
cache.totalCostLimit = 1024 * 1024 * 200 // 200 MB
return cache
}()
subscript(_ key: URL) -> UIImage? {
get { cache.object(forKey: key as NSURL) }
set { newValue == nil ? cache.removeObject(forKey: key as NSURL) : cache.setObject(newValue!, forKey: key as NSURL) }
}
}
import Combine
class ImageLoader: ObservableObject {
#Published var image: UIImage?
private(set) var isLoading = false
private let url: URL
private var cache: ImageCache?
private var cancellable: AnyCancellable?
private static let imageProcessingQueue = DispatchQueue(label: "image-processing")
init(url: URL, cache: ImageCache? = nil) {
self.url = url
self.cache = cache
}
deinit {
cancel()
}
func load() {
guard !isLoading else { return }
if let image = cache?[url] {
self.image = image
return
}
#warning("Caching the image are disabled")
cancellable = URLSession.shared.dataTaskPublisher(for: url)
.map { UIImage(data: $0.data) }
.replaceError(with: nil)
.handleEvents(receiveSubscription: { [weak self] _ in self?.onStart() },
// receiveOutput: { [weak self] in self?.cache($0) },
receiveCompletion: { [weak self] _ in self?.onFinish() },
receiveCancel: { [weak self] in self?.onFinish() })
.subscribe(on: Self.imageProcessingQueue)
.receive(on: DispatchQueue.main)
.sink { [weak self] in self?.image = $0 }
}
func cancel() {
cancellable?.cancel()
}
private func onStart() {
isLoading = true
}
private func onFinish() {
isLoading = false
}
private func cache(_ image: UIImage?) {
image.map { cache?[url] = $0 }
}
}
import SwiftUI
struct ImageCacheKey: EnvironmentKey {
static let defaultValue: ImageCache = TemporaryImageCache()
}
extension EnvironmentValues {
var imageCache: ImageCache {
get { self[ImageCacheKey.self] }
set { self[ImageCacheKey.self] = newValue }
}
}

Downloading images from S3 and showing them using SwiftUI results in high memory usage

Hey 👋 So I am downloading images from AWS S3 and show them in my app using a swiftUI LazyVGrid.
My code to download is the following:
class S3CacheFetcher: Fetcher {
typealias KeyType = MediaItemCacheInfo
typealias OutputType = NSData
func get(_ key: KeyType) -> AnyPublisher<OutputType, Error> {
return download(mediaItem: key).eraseToAnyPublisher()
}
private func download(mediaItem: KeyType) -> AnyPublisher<OutputType, Error>{
let BUCKET = "someBucket"
return Deferred {
Future { promise in
guard let key:String = S3CacheFetcher.getItemKey(mediaItem: mediaItem) else { fatalError("UserPoolID Error") }
print("Downloading image with key: \(key)")
AWSS3TransferUtility.default().downloadData(fromBucket: BUCKET,
key: key,
expression: nil) { (task, url, data, error) in
if let error = error{
print(error)
promise(.failure(error))
}else if let data = data{
// EDIT--------
let encrypt = S3CacheFetcher.encrypt(data: data)
let decrypt = S3CacheFetcher.decrypt(data: encrypt)
// EDIT--------
promise(.success(decrypt as NSData))
}
}
}
}
.eraseToAnyPublisher()
}
....
// EDIT---------
// In my code I have a static function that decrypts the images using CryptoKit.AES.GCM
// To test my problem I added these two functions that should stand for my decryption.
static var symmetricKey = SymmetricKey(size: .bits256)
static func encrypt(data: Data) -> Data{
return try! AES.GCM.seal(data, using: S3CacheFetcher.symmetricKey).combined!
}
static func decrypt(data: Data) -> Data{
return try! AES.GCM.open(AES.GCM.SealedBox(combined: data), using: S3CacheFetcher.symmetricKey)
}
}
My GridView:
struct AllPhotos: View {
#StateObject var mediaManager = MediaManager()
var body: some View {
ScrollView{
LazyVGrid(columns: columns, spacing: 3){
ForEach(mediaManager.mediaItems) { item in
VStack{
ImageView(downloader: ImageLoader(mediaItem: item, size: .large, parentAlbum: nil))
}
}
}
}
}
My ImageView I am using inside my GridView:
struct ImageView: View{
#StateObject var downloader: ImageLoader
var body: some View {
Image(uiImage: downloader.image ?? UIImage(systemName: "photo")!)
.resizable()
.aspectRatio(contentMode: .fill)
.onAppear(perform: {
downloader.load()
})
.onDisappear {
downloader.cancel()
}
}
}
And last but not least the ImageDownloader which is triggered when the image view is shown:
class ImageLoader: ObservableObject {
#Published var image: UIImage?
private(set) var isLoading = false
private var cancellable: AnyCancellable?
private(set) var mediaItem:MediaItem
private(set) var size: ThumbnailSizes
private(set) var parentAlbum: GetAlbum?
init(mediaItem: MediaItem, size: ThumbnailSizes, parentAlbum: GetAlbum?) {
self.mediaItem = mediaItem
self.size = size
self.parentAlbum = parentAlbum
}
deinit {
cancel()
self.image = nil
}
func load() {
guard !isLoading else { return }
// I use the Carlos cache library but for the sake of debugging I just use my Fetcher like below
cancellable = S3CacheFetcher().get(.init(parentAlbum: self.parentAlbum, size: self.size, cipher: self.mediaItem.cipher, ivNonce: self.mediaItem.ivNonce, mid: self.mediaItem.mid))
.map{ UIImage(data: $0 as Data)}
.replaceError(with: nil)
.handleEvents(receiveSubscription: { [weak self] _ in self?.onStart() },
receiveCompletion: { [weak self] _ in self?.onFinish() },
receiveCancel: { [weak self] in self?.onFinish() })
.receive(on: DispatchQueue.main)
.sink { [weak self] in self?.image = $0 }
}
func cancel() {
cancellable?.cancel()
self.image = nil
}
private func onStart() {
isLoading = true
}
private func onFinish() {
isLoading = false
}
}
So first of all before I describe my problem. Yes I know I have to cache those images for a smother experience. I did that but for the sake of debugging my memory issue I do not cache those images for now.
Expected behavior: Downloads images and displays them if the view is shown. Purges the images out of memory if the image view is not shown.
Actual behavior: Downloads the images and displays them but it does not purge them from memory once the image view has disappeared. If I scroll up and down for some period of time the memory usage is up in the Gb range and the app crashes.
If I use my persistent cache which grabs the images from disk with more or less the same logic for grabbing and displaying the images than everything works as expected and the memory usage is not higher than 50 Mb.
I am fairly new to Combine as well as SwiftUI so any help is much appreciated.

SwiftUI Drag and Drop files

I am trying to add a "Drag and Drop" gesture / function to my SwiftUI Mac application.
I want to drop files from my System/ Desktop into my Application. It is possbile in regular Swift, which I found. I am trying to do this in SwiftUI now.
I find a onDrop() function in SwiftUI for Views. However, it looks like that this is only for internal gestures inside my application. I want to drag files from outside.
In Swift you need to register your NSView, for dragged Types.
registerForDraggedTypes([kUTTypeFileURL,kUTTypeImage])
I thought of creating a NSViewRepresentable and wrap that into my SwiftUI view.
This is the code I came up with, however I can not call registerForDraggedTyped.
final class DragDropView: NSViewRepresentable {
func makeNSView(context: NSViewRepresentableContext<DragDropView>) -> NSView {
let view = NSView()
view.registerForDraggedTypes([NSPasteboard.PasteboardType.pdf, NSPasteboard.PasteboardType.png])
return view
}
func updateNSView(_ nsView: NSView, context: NSViewRepresentableContext<DragDropView>) {
}
Is there a simpler solution for that in SwiftUI? I would love to use that onDrop() function, but this is not working for external files, is it?
Here is a demo of drag & drop, tested with Xcode 11.4 / macOS 10.15.4.
Initial image is located on assets library, accepts drop (for simplicity only) as file url from Finder/Desktop (drop) and to TextEdit (drag), registers drag for TIFF representation.
struct TestImageDragDrop: View {
#State var image = NSImage(named: "image")
#State private var dragOver = false
var body: some View {
Image(nsImage: image ?? NSImage())
.onDrop(of: ["public.file-url"], isTargeted: $dragOver) { providers -> Bool in
providers.first?.loadDataRepresentation(forTypeIdentifier: "public.file-url", completionHandler: { (data, error) in
if let data = data, let path = NSString(data: data, encoding: 4), let url = URL(string: path as String) {
let image = NSImage(contentsOf: url)
DispatchQueue.main.async {
self.image = image
}
}
})
return true
}
.onDrag {
let data = self.image?.tiffRepresentation
let provider = NSItemProvider(item: data as NSSecureCoding?, typeIdentifier: kUTTypeTIFF as String)
provider.previewImageHandler = { (handler, _, _) -> Void in
handler?(data as NSSecureCoding?, nil)
}
return provider
}
.border(dragOver ? Color.red : Color.clear)
}
}
As OP noted, this is only for a Mac OS application.
If you want to
open from Finder
drag from Finder
AND drag from elsewhere (like a website or iMessage)
then try this (drag image from this view not included).
XCode 11+ / SwiftUI 1.0+ / Swift 5:
Required Extension for opening from Finder:
extension NSOpenPanel {
static func openImage(completion: #escaping (_ result: Result<NSImage, Error>) -> ()) {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = false
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowedFileTypes = ["jpg", "jpeg", "png", "heic"]
panel.canChooseFiles = true
panel.begin { (result) in
if result == .OK,
let url = panel.urls.first,
let image = NSImage(contentsOf: url) {
completion(.success(image))
} else {
completion(.failure(
NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to get file location"])
))
}
}
}
}
The SwiftUI Views
struct InputView: View {
#Binding var image: NSImage?
var body: some View {
VStack(spacing: 16) {
HStack {
Text("Input Image (PNG,JPG,JPEG,HEIC)")
Button(action: selectFile) {
Text("From Finder")
}
}
InputImageView(image: self.$image)
}
}
private func selectFile() {
NSOpenPanel.openImage { (result) in
if case let .success(image) = result {
self.image = image
}
}
}
}
struct InputImageView: View {
#Binding var image: NSImage?
var body: some View {
ZStack {
if self.image != nil {
Image(nsImage: self.image!)
.resizable()
.aspectRatio(contentMode: .fit)
} else {
Text("Drag and drop image file")
.frame(width: 320)
}
}
.frame(height: 320)
.background(Color.black.opacity(0.5))
.cornerRadius(8)
.onDrop(of: ["public.url","public.file-url"], isTargeted: nil) { (items) -> Bool in
if let item = items.first {
if let identifier = item.registeredTypeIdentifiers.first {
print("onDrop with identifier = \(identifier)")
if identifier == "public.url" || identifier == "public.file-url" {
item.loadItem(forTypeIdentifier: identifier, options: nil) { (urlData, error) in
DispatchQueue.main.async {
if let urlData = urlData as? Data {
let urll = NSURL(absoluteURLWithDataRepresentation: urlData, relativeTo: nil) as URL
if let img = NSImage(contentsOf: urll) {
self.image = img
print("got it")
}
}
}
}
}
}
return true
} else { print("item not here") return false }
}
}
}
Note: Have not needed to use the "public.image" identifier.
Optional Extensions if you need the result as PNG data (I did to upload to Firebase Storage):
extension NSBitmapImageRep {
var png: Data? { representation(using: .png, properties: [.compressionFactor:0.05]) }
}
extension Data {
var bitmap: NSBitmapImageRep? { NSBitmapImageRep(data: self) }
}
extension NSImage {
var png: Data? { tiffRepresentation?.bitmap?.png }
}
// usage
let image = NSImage(...)
if let data = image.png {
// do something further with the data
}
Can’t you use onDrop(of:isTargeted:perform:)? You can pass your array of supported types in the of argument.

SwiftUI can't get image from download url

I have the following code to load an image from a download url and display it as an UIImage.
I expected it to work, but somehow, solely the placeholder image 'ccc' is being displayed, and not the actual image from the download url. How so?
My urls are being fetched from a database and kind of look like this:
https://firebasestorage.googleapis.com/v0/b/.../o/P...alt=media&token=...-579f...da
struct ShelterView: View {
var title: String
var background: String
var available: Bool
var distance: Double
var gender: String
#ObservedObject private var imageLoader: Loader
init(title: String, background: String, available: Bool, distance: Double, gender: String) {
self.title = title
self.background = background
self.available = available
self.distance = distance
self.gender = gender
self.imageLoader = Loader(background)
}
var image: UIImage? {
imageLoader.data.flatMap(UIImage.init)
}
var body: some View {
VStack {
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 0) {
Text(title)
.font(Font.custom("Helvetica Now Display Bold", size: 30))
.foregroundColor(.white)
.padding(15)
.lineLimit(2)
HStack(spacing: 25) {
IconInfo(image: "bed.double.fill", text: String(available), color: .white)
if gender != "" {
IconInfo(image: "person.fill", text: gender, color: .white)
}
}
.padding(.leading, 15)
}
Spacer()
IconInfo(image: "mappin.circle.fill", text: String(distance) + " miles away", color: .white)
.padding(15)
}
Spacer()
}
.background(
Image(uiImage: image ?? UIImage(named: "ccc")!) <-- HERE
.brightness(-0.11)
.frame(width: 255, height: 360)
)
.frame(width: 255, height: 360)
.cornerRadius(30)
.shadow(color: Color("shadow"), radius: 10, x: 0, y: 10)
}
}
final class Loader: ObservableObject {
var task: URLSessionDataTask!
#Published var data: Data? = nil
init(_ urlString: String) {
print(urlString)
let url = URL(string: urlString)
task = URLSession.shared.dataTask(with: url!, completionHandler: { data, _, _ in
DispatchQueue.main.async {
self.data = data
}
})
task.resume()
}
deinit {
task.cancel()
}
}
Your image is a plain old var which happens to be nil when the View is built. SwiftUI only rebuilds itself in response to changes in #ObservedObject, #State, or #Binding, so move your image to an #Published property on your imageLoader and it will work. Here is my caching image View:
import SwiftUI
import Combine
import UIKit
class ImageCache {
enum Error: Swift.Error {
case dataConversionFailed
case sessionError(Swift.Error)
}
static let shared = ImageCache()
private let cache = NSCache<NSURL, UIImage>()
private init() { }
static func image(for url: URL?) -> AnyPublisher<UIImage?, ImageCache.Error> {
guard let url = url else {
return Empty().eraseToAnyPublisher()
}
guard let image = shared.cache.object(forKey: url as NSURL) else {
return URLSession
.shared
.dataTaskPublisher(for: url)
.tryMap { (tuple) -> UIImage in
let (data, _) = tuple
guard let image = UIImage(data: data) else {
throw Error.dataConversionFailed
}
shared.cache.setObject(image, forKey: url as NSURL)
return image
}
.mapError({ error in Error.sessionError(error) })
.eraseToAnyPublisher()
}
return Just(image)
.mapError({ _ in fatalError() })
.eraseToAnyPublisher()
}
}
class ImageModel: ObservableObject {
#Published var image: UIImage? = nil
var cacheSubscription: AnyCancellable?
init(url: URL?) {
cacheSubscription = ImageCache
.image(for: url)
.replaceError(with: nil)
.receive(on: RunLoop.main, options: .none)
.assign(to: \.image, on: self)
}
}
struct RemoteImage : View {
#ObservedObject var imageModel: ImageModel
private let contentMode: ContentMode
init(url: URL?, contentMode: ContentMode = .fit) {
imageModel = ImageModel(url: url)
self.contentMode = contentMode
}
var body: some View {
imageModel
.image
.map { Image(uiImage:$0).resizable().aspectRatio(contentMode: contentMode) }
?? Image(systemName: "questionmark").resizable().aspectRatio(contentMode: contentMode)
}
}