SwiftUI Image in App not loading when using Combine - swift

I have been trying to asynchronously load an image in my app using combine. Currently all the other pieces of data are loading fine, but my image seem to be stuck in a progress view. Why? I am not too familiar with how combine works as I have been trying to follow a tutorial and adapting it to fit my needs, which is why I ran into this problem.
This is my code:
Main View:
import SwiftUI
struct ApodView: View {
#StateObject var vm = ApodViewModel()
var body: some View {
ZStack {
// Background Layer
Color.theme.background
.ignoresSafeArea()
// Content Layer
VStack() {
Text(vm.apodData?.title ?? "Placeholder")
.font(.title)
.fontWeight(.bold)
.multilineTextAlignment(.center)
.foregroundColor(Color.theme.accent)
ApodImageView(apodData: vm.apodData ?? ApodModel(date: "", explanation: "", url: "", thumbnailUrl: "", title: ""))
ZStack() {
Color.theme.backgroundTextColor
ScrollView(showsIndicators: false) {
Text(vm.apodData?.explanation ?? "Loading...")
.font(.body)
.fontWeight(.semibold)
.foregroundColor(Color.theme.accent)
.multilineTextAlignment(.center)
.padding()
}
}
.cornerRadius(10)
}
.padding()
}
}
}
ImageView:
import SwiftUI
struct ApodImageView: View {
#StateObject var vm: ApodImageViewModel
init(apodData: ApodModel) {
_vm = StateObject(wrappedValue: ApodImageViewModel(apodData: apodData))
}
var body: some View {
ZStack {
if let image = vm.image {
Image(uiImage: image)
.resizable()
.scaledToFit()
} else if vm.isLoading {
ProgressView()
} else {
Image(systemName: "questionmark")
.foregroundColor(Color.theme.secondaryText)
}
}
.frame(maxWidth: .infinity, maxHeight: 250)
.cornerRadius(10)
}
}
Image ViewModel:
import Foundation
import SwiftUI
import Combine
class ApodImageViewModel: ObservableObject {
#Published var image: UIImage?
#Published var isLoading: Bool = false
private let apodData: ApodModel
private let dataService: ApodImageService
private var cancellables = Set<AnyCancellable>()
init(apodData: ApodModel) {
self.apodData = apodData
self.dataService = ApodImageService(apodData: apodData)
self.addSubscribers()
self.isLoading = true
}
private func addSubscribers() {
dataService.$image
.sink { [weak self] _ in
self?.isLoading = false
} receiveValue: { [weak self] returnedImage in
self?.image = returnedImage
}
.store(in: &cancellables)
}
}
Networking For Image:
import Foundation
import SwiftUI
import Combine
class ApodImageService: ObservableObject {
#Published var image: UIImage?
private var imageSubscription: AnyCancellable?
private let apodData: ApodModel
init(apodData: ApodModel) {
self.apodData = apodData
getApodImage()
}
private func getApodImage() {
guard let url = URL(string: apodData.thumbnailUrl ?? apodData.url) else { return }
imageSubscription = NetworkingManager.download(url: url)
.tryMap({ data -> UIImage? in
return UIImage(data: data)
})
.sink(receiveCompletion: NetworkingManager.handleCompletion, receiveValue: { [weak self] returnedImage in
self?.image = returnedImage
self?.imageSubscription?.cancel()
})
}
}
General Networking Code:
import Foundation
import Combine
class NetworkingManager {
enum NetworkingError: LocalizedError {
case badURLResponse(url: URL)
case unknown
var errorDescription: String? {
switch self {
case .badURLResponse(url: let url): return "Bad response from URL: \(url)"
case .unknown: return "Unknown Error Returned"
}
}
}
static func download(url: URL) -> AnyPublisher<Data, Error> {
return URLSession.shared.dataTaskPublisher(for: url)
.subscribe(on: DispatchQueue.global(qos: .background))
.tryMap({ try handleURLResponse(output: $0, url: url) })
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}

Where you have:
private func addSubscribers() {
dataService.$image
.sink { [weak self] _ in
self?.isLoading = false
} receiveValue: { [weak self] returnedImage in
self?.image = returnedImage
}
.store(in: &cancellables)
}
You are subscribing to the published value of the image property. That image property stream will never complete. It is an infinite sequence tracking the value of that property over time "forever".
I don't think your receiveCompletion will ever be called so self?.isLoading = false will never happen.

Related

SwiftUI functions execute in wrong order

I'm building a SwiftUI app with CRUD functionality with a mysql db. The CRUD operations work fine and after the create, update, or delete function the read function is called to get the updated data from the db. When I run the app the read function seems to be called before the create, update of delete function if I look at the console output so I only see the mutation after running the app again. Any idea how to fix this?
This is my create code
struct NewPostView: View {
#EnvironmentObject var viewModel: ViewModel
#Binding var isPresented: Bool
#Binding var title: String
#Binding var post: String
#State var isAlert = false
var body: some View {
NavigationView {
ZStack {
Color.gray.opacity(0.1).edgesIgnoringSafeArea(.all)
VStack(alignment: .leading) {
Text("Create new post")
.font(Font.system(size: 16, weight: .bold))
TextField("Title", text: $title)
.padding()
.background(Color.white)
.cornerRadius(6)
.padding(.bottom)
TextField("Write something...", text: $post)
.padding()
.background(Color.white)
.cornerRadius(6)
.padding(.bottom)
Spacer()
}.padding()
.alert(isPresented: $isAlert, content: {
let title = Text("No data")
let message = Text("Please fill your title and post")
return Alert(title: title, message: message)
})
}
.navigationBarTitle("New post", displayMode: .inline)
.navigationBarItems(leading: leading, trailing: trailing)
}
}
var leading: some View {
Button(action: {
isPresented.toggle()
}, label: {
Text("Cancel")
})
}
var trailing: some View {
Button(action: {
if title != "" && post != "" {
let parameters: [String: Any] = ["title": title, "post": post]
print(parameters)
viewModel.createPost(parameters: parameters)
viewModel.fetchPosts()
isPresented.toggle()
} else {
isAlert.toggle()
}
}, label: {
Text("Post")
})
}
}
ViewModel
class ViewModel: ObservableObject {
#Published var items = [PostModel]()
let prefixURL = "https://asreconnect.nl/CRM/php/app"
init() {
fetchPosts()
}
//MARK: - retrieve data
func fetchPosts() {
guard let url = URL(string: "\(prefixURL)/posts.php") else {
print("Not found url")
return
}
URLSession.shared.dataTask(with: url) { (data, res, error) in
if error != nil {
print(data!)
print("error", error?.localizedDescription ?? "")
return
}
do {
if let data = data {
print(data)
let result = try JSONDecoder().decode(DataModel.self, from: data)
DispatchQueue.main.async {
self.items = result.data
print(result)
}
} else {
print("No data")
}
} catch let JsonError {
print("fetch json error:", JsonError.localizedDescription)
print(String(describing: JsonError))
}
}.resume()
}
//MARK: - create data
func createPost(parameters: [String: Any]) {
guard let url = URL(string: "\(prefixURL)/create.php") else {
print("Not found url")
return
}
let data = try! JSONSerialization.data(withJSONObject: parameters)
print(data)
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpBody = data
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
URLSession.shared.dataTask(with: request) { (data, res, error) in
if error != nil {
print("error", error?.localizedDescription ?? "")
return
}
do {
if let data = data {
let result = try JSONDecoder().decode(DataModel.self, from: data)
DispatchQueue.main.async {
print(result)
}
} else {
print("No data")
}
} catch let JsonError {
print("fetch json error:", JsonError.localizedDescription)
}
}.resume()
}
}

SwiftUI Menu with label an image and a text

As you can see in the image I would like to put the photo inside that drop down menu where it says Paul.
I tried to insert it but I get a strange effect, it happens that the image takes up all the space, you can't see the text anymore.
Can you give me a hand?
import Foundation
import SwiftUI
struct RemoteImage: View {
private enum LoadState {
case loading, success, failure
}
private class Loader: ObservableObject {
var data = Data()
var state = LoadState.loading
init(url: String) {
guard let parsedURL = URL(string: url) else {
fatalError("Invalid URL: \(url)")
}
URLSession.shared.dataTask(with: parsedURL) { data, response, error in
if let data = data, data.count > 0 {
self.data = data
self.state = .success
} else {
self.state = .failure
}
DispatchQueue.main.async {
self.objectWillChange.send()
}
}.resume()
}
}
#StateObject private var loader: Loader
var loading: Image
var failure: Image
var body: some View {
selectImage()
.resizable()
}
init(url: String, loading: Image = Image(systemName: "photo"), failure: Image = Image(systemName: "multiply.circle")) {
_loader = StateObject(wrappedValue: Loader(url: url))
self.loading = loading
self.failure = failure
}
private func selectImage() -> Image {
switch loader.state {
case .loading:
return loading
case .failure:
return failure
default:
if let image = NSImage(data: loader.data) {
return Image(nsImage: image)
} else {
return failure
}
}
}
}
struct Test: View {
var body: some View {
VStack(alignment: .trailing){
Menu {
Button(action: {
NSApplication.shared.terminate(self)
}) {
Text("Esci")
}
} label: {
RemoteImage(url: "https://upload.wikimedia.org/wikipedia/commons/thumb/9/96/Leonardo_Dicaprio_Cannes_2019_2.jpg/440px-Leonardo_Dicaprio_Cannes_2019_2.jpg")
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
.cornerRadius(10)
Text("Paul")
}
.menuStyle(BorderlessButtonMenuStyle())
.frame(width: 80, height: 16)
.padding(3)
.cornerRadius(4)
.help("Impostazioni")
}
.frame(width: 360, height: 360)
}
}

How can I remove a image from the cache with Combine in SwiftUI?

When I delete the data from array, the operation is successful, but only the picture does not change. The image still remains in the cache. However, when I close and open the application, the application works fine.
how can i update the cache?
Image Loader
import Combine
class ImageLoader: ObservableObject {
#Published var image: UIImage?
private let url: String
private var cancellable: AnyCancellable?
private var cache: ImageCache?
init(url: String, cache: ImageCache? = nil) {
self.url = url
self.cache = cache
}
deinit {
cancel()
}
func load() {
guard let cacheURL = URL(string: url) else { return }
if let image = cache?[cacheURL] {
self.image = image
return
}
guard let url = URL(string: url) else { return }
cancellable = URLSession.shared.dataTaskPublisher(for: url)
.map { UIImage(data: $0.data) }
.replaceError(with: nil)
.handleEvents(receiveOutput: { [weak self] in self?.cache($0) })
.receive(on: DispatchQueue.main)
.sink { [weak self] in self?.image = $0 }
}
private func cache(_ image: UIImage?) {
guard let cacheURL = URL(string: url) else { return }
image.map { cache?[cacheURL] = $0 }
}
func cancel() {
cancellable?.cancel()
}
}
Async Image
import Combine
struct AsyncImage<Placeholder: View>: View {
#StateObject private var loader: ImageLoader
private let placeholder: Placeholder
init(url: String, #ViewBuilder placeholder: () -> Placeholder) {
self.placeholder = placeholder()
_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(uiImage: loader.image!)
.resizable()
} else {
placeholder
}
}
}
}
Image Cache
protocol ImageCache {
subscript(_ url: URL) -> UIImage? { get set }
}
Temporary Image Cache
struct TemporaryImageCache: ImageCache {
private let cache = NSCache<NSURL, UIImage>()
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) }
}
}
Image Cache Key
struct ImageCacheKey: EnvironmentKey {
static let defaultValue: ImageCache = TemporaryImageCache()
}
extension EnvironmentValues {
var imageCache: ImageCache {
get { self[ImageCacheKey.self] }
set { self[ImageCacheKey.self] = newValue }
}
}
Async Image Using
VStack {
ZStack(alignment: .bottomTrailing) {
AsyncImage(url: "image url") {
Text("Loading")
}
.aspectRatio(contentMode: .fill)
.frame(width: 120, height: 180, alignment: .center)
.cornerRadius(15)
....
}
}

SwiftUI Network Image show different views on loading and error

I would like to make sure that when the image is being downloaded the ProgressView is visible and if the url being passed is invalid (empty), a placeholder is put.
How can I do this?
Code:
import Foundation
import SwiftUI
// Download image from URL
struct NetworkImage: View {
public let url: URL?
var body: some View {
Group {
if let url = url, let imageData = try? Data(contentsOf: url),
let uiImage = UIImage(data: imageData) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
}
else {
ProgressView()
}
}
}
}
struct NetworkImage_Previews: PreviewProvider {
static var previews: some View {
let url = [
"",
"http://google.com",
"https://yt3.ggpht.com/a/AATXAJxAbUTyYnKsycQjZzCdL_gWVbJYVy4mVaVGQ8kRMQ=s176-c-k-c0x00ffffff-no-rj"
]
NetworkImage(url: URL(string: url[0])!)
}
}
You can create a ViewModel to handle the downloading logic:
extension NetworkImage {
class ViewModel: ObservableObject {
#Published var imageData: Data?
#Published var isLoading = false
private var cancellables = Set<AnyCancellable>()
func loadImage(from url: URL?) {
isLoading = true
guard let url = url else {
isLoading = false
return
}
URLSession.shared.dataTaskPublisher(for: url)
.map { $0.data }
.replaceError(with: nil)
.receive(on: DispatchQueue.main)
.sink { [weak self] in
self?.imageData = $0
self?.isLoading = false
}
.store(in: &cancellables)
}
}
}
And modify your NetworkImage to display a placeholder image as well:
struct NetworkImage: View {
#StateObject private var viewModel = ViewModel()
let url: URL?
var body: some View {
Group {
if let data = viewModel.imageData, let uiImage = UIImage(data: data) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
} else if viewModel.isLoading {
ProgressView()
} else {
Image(systemName: "photo")
}
}
.onAppear {
viewModel.loadImage(from: url)
}
}
}
Then you can use it like:
NetworkImage(url: URL(string: "https://stackoverflow.design/assets/img/logos/so/logo-stackoverflow.png"))
(Note that the url parameter is not force unwrapped).

Updating image view in SwiftUI after downloading image

On the click of a button I am trying to download a new random image and update the view. When the app loads it displays the downloaded image. When the button is clicked the image seems to download but the view is never updated and displays the place holder image. Am I missing something here, any ideas? Here is a simplified version.
import SwiftUI
struct ContentView : View {
#State var url = "https://robohash.org/random.png"
var body: some View {
VStack {
Button(action: {
self.url = "https://robohash.org/\(Int.random(in:0 ..< 10)).png"
}) {
Text("Get Random Robot Image")
}
URLImage(url: url)
}
}
}
class ImageLoader: BindableObject {
var downloadedImage: UIImage?
let didChange = PassthroughSubject<ImageLoader?, Never>()
func load(url: String) {
guard let imageUrl = URL(string: url) else {
fatalError("Image URL is not correct")
}
URLSession.shared.dataTask(with: imageUrl) { data, response, error in
guard let data = data, error == nil else {
DispatchQueue.main.async {
self.didChange.send(nil)
}
return
}
self.downloadedImage = UIImage(data: data)
DispatchQueue.main.async {
print("downloaded image")
self.didChange.send(self)
}
}.resume()
}
}
import SwiftUI
struct URLImage : View {
#ObjectBinding private var imageLoader = ImageLoader()
var placeholder: Image
init(url: String, placeholder: Image = Image(systemName: "photo")) {
self.placeholder = placeholder
self.imageLoader.load(url: url)
}
var body: some View {
if let uiImage = self.imageLoader.downloadedImage {
print("return downloaded image")
return Image(uiImage: uiImage)
} else {
return placeholder
}
}
}
The problem seems to be related to some kind of lost synchronization between the ContentView and the ImageURL (that happens after the button click event).
A possible workaround is making the ImageURL a #State property of the ContentView.
After that, inside the scope of the button click event, we can call the image.imageLoader.load(url: ) method. As the download of the image ends, the publisher (didChange) will notify the ImageURL and then the change is correctly propagated to the ContentView.
import SwiftUI
import Combine
enum ImageURLError: Error {
case dataIsNotAnImage
}
class ImageLoader: BindableObject {
/*
init(url: URL) {
self.url = url
}
private let url: URL */
let id: String = UUID().uuidString
var didChange = PassthroughSubject<Void, Never>()
var image: UIImage? {
didSet {
DispatchQueue.main.async {
self.didChange.send()
}
}
}
func load(url: URL) {
print(#function)
self.image = nil
URLSession.shared.dataTask(with: url) { (data, res, error) in
guard error == nil else {
return
}
guard
let data = data,
let image = UIImage(data: data)
else {
return
}
self.image = image
}.resume()
}
}
URLImage view:
struct URLImage : View {
init() {
self.placeholder = Image(systemName: "photo")
self.imageLoader = ImageLoader()
}
#ObjectBinding var imageLoader: ImageLoader
var placeholder: Image
var body: some View {
imageLoader.image == nil ?
placeholder : Image(uiImage: imageLoader.image!)
}
}
ContentView:
struct ContentView : View {
#State var url: String = "https://robohash.org/random.png"
#State var image: URLImage = URLImage()
var body: some View {
VStack {
Button(action: {
self.url = "https://robohash.org/\(Int.random(in: 0 ..< 10)).png"
self.image.imageLoader.load(url: URL(string: self.url)!)
}) {
Text("Get Random Robot Image")
}
image
}
}
}
Anyway I will try to investigate the problem and if I will know something new I will modify my answer.