SwiftUI + Combine framework + MVVM Publisher return empty list - mvvm

I am using the MVVM architecture in Swift-UI with Combine Framework and Alamofire.
However the data is being returned to observable object and is printing means the Alamofire and published data from api layers but from obserable object its not going to view.
I print the response and its printing in publisher but not returning back to view.
Following is the observable object.
import SwiftUI
import Combine
class CountryViewModel: ObservableObject {
#Published var countryResponse:[CountryResponse] = []
#Published var isLoggedIn = false
#Published var isLoading = false
#Published var shouldNavigate = false
private var disposables: Set<AnyCancellable> = []
var loginHandler = CountryHandler()
#Published var woofUrl = ""
private var isLoadingPublisher: AnyPublisher<Bool, Never> {
loginHandler.$isLoading
.receive(on: RunLoop.main)
.map { $0 }
.eraseToAnyPublisher()
}
private var isAuthenticatedPublisher: AnyPublisher<[CountryResponse], Never> {
loginHandler.$countryResponse
.receive(on: RunLoop.main)
.map { response in
guard let response = response else {
return []
}
print(response)
return response
}
.eraseToAnyPublisher()
}
init() {
countryResponse = []
isLoadingPublisher
.receive(on: RunLoop.main)
.assign(to: \.isLoading, on: self)
.store(in: &disposables)
isAuthenticatedPublisher.map([CountryResponse].init)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] value in
guard let self = self else { return }
switch value {
case .failure:
self.countryResponse = []
case .finished:
break
}
}, receiveValue: { [weak self] weather in
guard let self = self else { return }
self.countryResponse = weather
})
.store(in: &disposables)
// isAuthenticatedPublisher
// .receive(on: RunLoop.main)
// .assign(to: \.countryResponse, on: self)
// .store(in: &disposables)
}
public func getAllCountries(){
loginHandler.getCountryList()
}
public func getAllUnis(_ id:Int){
loginHandler.getCountryList()
}
}
View Code
struct ContentViewCollection: View {
#Binding var selectedCountryId:Int
var axis : Axis.Set = .horizontal
var viewModel:CountryViewModel
#State var countries:[CountryResponse] = []
var body: some View {
ScrollView (.horizontal, showsIndicators: false) {
HStack {
ForEach(self.viewModel.countryResponse) { (postData) in
Button(action: {
self.selectedCountryId = postData.countryID ?? 0
}){
WebImage(url: self.imageURL(postData.countryName ?? ""))
.resizable()
.indicator(.activity)
.scaledToFit()
.frame(minWidth: 40,maxWidth: 40, minHeight: 40, maxHeight: 40, alignment: .center)
}
}
}
}.onAppear() {
self.loadData()
}
}
}
Thanks in advance.

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.

Combine: Publisher sends text change even once

I want to observe changes on UISearchController's text, here is my setup (and I'm pretty new to Combine):
private var searchQuery: String? {
didSet {
print(searchQuery)
}
}
private var disposable: AnyCancellable?
func bindSearchQuery() {
disposable = searchController.searchBar.publisher(for: \.text)
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.sink { value in
if let _value = value {
self.searchQuery = _value
}
}
}
And I see this once only in the console, on load:
Optional("")
I had to create an AnyPublisher object then received its changes on an AnyCancellable object:
final class SearchResult {
var resultText: String? {
didSet {
print(resultText)
}
}
}
///
#Published var searchQuery: String = ""
private var validateQuery: AnyPublisher<String?, Never> {
return $searchQuery
.debounce(for: 0.3, scheduler: RunLoop.main)
.removeDuplicates()
.flatMap { query in
return Future { promise in
self.search(query: query) { result in
switch result {
case .success(let _output):
print(_output)
promise(.success(_output))
case .failure:
print("Failed search")
promise(.success(nil))
}
}
}
}
.eraseToAnyPublisher()
}
private var cancelable: AnyCancellable?
private var result = SearchResult()
///
func bindSearchQuery() {
cancelable = validateQuery
.receive(on: RunLoop.main)
.assign(to: \.resultText, on: result)
}
And everytime user types in the search box I update searchQuery:
func updateSearchResults(for searchController: UISearchController) {
self.searchQuery = searchController.searchBar.text ?? ""
}

Update View Only After Aync Is Resolved with Completion Handler

I'm trying to update my view, only after the Async call is resolved. In the below code the arrayOfTodos.items comes in asynchronously a little after TodoListApp is rendered. The problem I'm having is that when onAppear runs, self.asyncTodoList.items is always empty since it hasn't received the values of the array yet from the network call. I'm stuck trying to figure out how to hold off on running onAppear until after the Promise is resolved, like with a completion handler?? And depending on the results of the network call, then modify the view. Thanks for any help! I've been stuck on this longer than I'll ever admit!
struct ContentView: View {
#StateObject var arrayOfTodos = AsyncGetTodosNetworkCall()
var body: some View {
TodoListApp(asyncTodoList: arrayOfTodos)
}
}
struct TodoListApp: View {
#ObservedObject var asyncTodoList: AsyncGetTodosNetworkCall
#State private var showPopUp: Bool = false
var body: some View {
NavigationView {
ZStack {
VStack {
Text("Top Area")
Text("List Area")
}
if self.showPopUp == true {
VStack {
Text("THIS IS MY POPUP!")
Text("No Items Added Yet")
}.frame(width: 300, height: 400)
}
}.onAppear {
let arrayItems = self.asyncTodoList
if arrayItems.items.isEmpty {
self.showPopUp = true
}
/*HERE! arrayItems.items.isEmpty is ALWAYS empty when onAppear
runs since it's asynchronous. What I'm trying to do is only
show the popup if the array is empty after the promise is
resolved.
What is happening is even if array resolved with multiple todos,
the popup is still showing because it was initially empty on
first run. */
}
}
}
}
class AsyncGetTodosNetworkCall: ObservableObject {
#AppStorage(DBUser.userID) var currentUserId: String?
private var REF_USERS = DB_BASE.collection(DBCOLLECTION.appUsers)
#Published var items = [TodoItem]()
func fetchTodos(toDetach: Bool) {
guard let userID = currentUserId else {
return
}
let userDoc = REF_USERS.document(String(userID))
.collection(DBCOLLECTION.todos)
.addSnapshotListener({ (querySnapshot, error) in
guard let documents = querySnapshot?.documents else {
print("No Documents Found")
return
}
self.items = documents.map { document -> TodoItem in
let todoID = document.documentID
let todoName = document.get(ToDo.todoName) as? String ?? ""
let todoCompleted = document.get(Todo.todoCompleted) as? Bool ?? false
return TodoItem(
id: todoID,
todoName: todoName,
todoCompleted: todoCompleted
)
}
})
if toDetach == true {
userDoc.remove()
}
}
}
While preparing my question, i found my own answer. Here it is in case someone down the road might run into the same issue.
struct ContentView: View {
#StateObject var arrayOfTodos = AsyncGetTodosNetworkCall()
#State var hasNoTodos: Bool = false
func getData() {
self.arrayOfTodos.fetchTodos(toDetach: false) { noTodos in
if noTodos {
self.hasNoTodos = true
}
}
}
func removeListeners() {
self.arrayOfTodos.fetchTodos(toDetach: true)
}
var body: some View {
TabView {
TodoListApp(asyncTodoList: arrayOfTodos, hasNoTodos : self.$hasNoTodos)
}.onAppear(perform: {
self.getData()
}).onDisappear(perform: {
self.removeListeners()
})
}
}
struct TodoListApp: View {
#ObservedObject var asyncTodoList: AsyncGetTodosNetworkCall
#Binding var hasNoTodos: Bool
#State private var hidePopUp: Bool = false
var body: some View {
NavigationView {
ZStack {
VStack {
Text("Top Area")
ScrollView {
LazyVStack {
ForEach(asyncTodoList.items) { item in
HStack {
Text("\(item.name)")
Spacer()
Text("Value")
}
}
}
}
}
if self.hasNoTodos == true {
if self.hidePopUp == false {
VStack {
Text("THIS IS MY POPUP!")
Text("No Items Added Yet")
}.frame(width: 300, height: 400)
}
}
}
}
}
}
class AsyncGetTodosNetworkCall: ObservableObject {
#AppStorage(DBUser.userID) var currentUserId: String?
private var REF_USERS = DB_BASE.collection(DBCOLLECTION.appUsers)
#Published var items = [TodoItem]()
func fetchTodos(toDetach: Bool, handler: #escaping (_ noTodos: Bool) -> ()) {
guard let userID = currentUserId else {
handler(true)
return
}
let userDoc = REF_USERS.document(String(userID))
.collection(DBCOLLECTION.todos)
.addSnapshotListener({ (querySnapshot, error) in
guard let documents = querySnapshot?.documents else {
print("No Documents Found")
handler(true)
return
}
self.items = documents.map { document -> TodoItem in
let todoID = document.documentID
let todoName = document.get(ToDo.todoName) as? String ?? ""
let todoCompleted = document.get(Todo.todoCompleted) as? Bool ?? false
return TodoItem(
id: todoID,
todoName: todoName,
todoCompleted: todoCompleted
)
}
handler(false)
})
if toDetach == true {
userDoc.remove()
}
}
}

SwiftUI ObservableObject with #Published array

The UI will update when the array is reassigned to a whole new array, but if I perform a remove on the array the UI will not update.
I was thinking maybe since it was still pointing to the same place in memory the UI wouldn’t be notified of the change. However, I added a didSet property observer to print every time the array was changed and the print statement executed on the remove.
I’m at a loss for how to debug further. Any help would be appreciated!
Code:
Here are the views. There is a movie list view which displays a grid of movie views
struct MovieListView: View {
#ObservedObject var viewModel: MovieListViewModel
init(viewModel: MovieListViewModel) {
self.viewModel = viewModel
}
var body: some View {
ZStack {
ScrollView(.vertical, showsIndicators: false) {
LazyVGrid(columns: [
GridItem(.flexible(minimum: 100, maximum: 200), alignment: .top),
GridItem(.flexible(minimum: 100, maximum: 200), alignment: .top)
], spacing: 12, content: {
ForEach(0 ..< viewModel.movies.count, id: \.self) { index in
viewModel.viewForMovie(viewModel.movies[index])
}
})
.id(UUID()) //For ignoring animation
}
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: Color.blue))
.scaleEffect(1.5, anchor: .center)
.opacity(self.viewModel.loadingMovies ? 1 : 0)
}.onAppear { self.viewModel.loadMovies() }
}
}
struct MovieView: View {
#ObservedObject var viewModel: MovieViewModel
var onDelete: (Movie) -> Void
init(viewModel: MovieViewModel, onDelete: #escaping (Movie) -> Void) {
self.viewModel = viewModel
self.onDelete = onDelete
}
var body: some View {
TmdbMovieView(vm: self.viewModel.tmdbViewModel)
.contentShape(RoundedRectangle(cornerRadius: Constants.MovieViews.movieViewCornerRadius, style: .continuous)) //Prevents sharp edges on context menu
.contextMenu {
Button {
self.onDelete(viewModel.movie)
} label: {
Label("Remove from list", systemImage: Constants.Icons.remove)
}
}
}
}
Here's the movie list view model which specifies what view to use for the movies and exposes the movies from the movie list controller
class MovieListViewModel: ObservableObject {
let name: String
var movies: [Movie] {
return self.movieListController.movies
}
#Published var loadingMovies = false
let movieListController: MovieListController
var viewForMovie: (Movie) -> MovieView
init(name: String, movieListController: MovieListController, viewForMovie: #escaping (Movie) -> MovieView) {
self.name = name
self.movieListController = movieListController
self.viewForMovie = viewForMovie
}
func loadMovies() {
DispatchQueue.main.async {
self.loadingMovies = true
}
self.movieListController.getMovies(complete: onLoadMoviesComplete)
}
private func onLoadMoviesComplete(success: Bool) {
if !success {
//TODO: Handle load fail
}
DispatchQueue.main.async {
self.loadingMovies = false
}
}
func deleteMovie(movie: Movie) {
self.movieListController.delete(movie: movie, complete: onDeleteComplete)
}
private func onDeleteComplete(success: Bool) {
if success {
loadMovies() //TODO: Need this because movies already triggered a change, but the view won't update???
} else {
//TODO: Handle delete fail
}
}
}
Here's my view model factory that creates the view model instance and ties the delete callback on the movie view to the view model
class ViewModelFactory {
let movieListController = MovieListController(id: 1)
func makeMovieListViewModel() -> MovieListViewModel {
let viewModel = MovieListViewModel(
name: "Test",
movieListController: self.movieListController,
viewForMovie: { [unowned self] in
MovieView(viewModel: self.makeMovieViewModel(for: $0), onDelete: {_ in})
}
)
viewModel.viewForMovie = { [unowned self] in
MovieView(viewModel: self.makeMovieViewModel(for: $0), onDelete: viewModel.deleteMovie)
}
return viewModel
}
func makeMovieViewModel(for movie: Movie) -> MovieViewModel {
return MovieViewModel(movie: movie)
}
}
And here is the controller which handles actually hitting my api. The get movies function updates the movies array and the UI updates accordingly. However, the delete function does not cause the UI to update
class MovieListController: ObservableObject {
#Published var movies: [Movie] = []
private var id: Int
init(id: Int) {
self.id = id
}
func getMovies(complete: #escaping (Bool) -> Void) {
guard let moviesUrl = URL(string: "\(Constants.Urls.movieLists)/\(id)\(Constants.Urls.moviesPath)") else {
print("Invalid url...")
complete(false)
return
}
URLSession.shared.dataTask(with: moviesUrl) { data, response, error in
guard let data = data else { return }
do {
var decodedMovies = try JSONDecoder().decode([Movie].self, from: data)
decodedMovies.sort { $0.id < $1.id }
self.movies = decodedMovies
complete(true)
} catch {
print("Failed to decode: \(error)")
complete(false)
}
}.resume()
}
func delete(movie: Movie, complete: #escaping (Bool) -> Void) {
guard let deleteMovieUrl = URL(string: "\(Constants.Urls.movieLists)/\(self.id)\(Constants.Urls.moviesPath)/\(movie.id)") else {
print("Invalid url...")
complete(false)
return
}
var request = URLRequest(url: deleteMovieUrl)
request.httpMethod = "DELETE"
URLSession.shared.dataTask(with: request) { data, response, error in
guard let response = response as? HTTPURLResponse,
error == nil else { // check for fundamental networking error
print("error", error ?? "Unknown error")
complete(false)
return
}
guard (200 ... 299) ~= response.statusCode else { // check for http errors
print("statusCode should be 2xx, but is \(response.statusCode)")
print("response = \(response)")
complete(false)
return
}
if let idx = self.movies.firstIndex(where: { $0.id == movie.id }) {
self.movies.remove(at: idx)
}
complete(true)
}.resume()
}
}

Update view with observed objects of observed array in swiftui

I'm having an Image holder that would load the thumdnail on init and allow for download later on. My issue is that the view is not updated with the images after I load them. After pressing the load button for the second time, my first images are then displayed.
I'm having trouble finding the reason behind this behaviour.
The image holder :
class MyImage: ObservableObject {
private static let sessionProcessingQueue = DispatchQueue(label: "SessionProcessingQueue")
#Published var thumbnail: UIImage?
#Published var loaded: Bool
var fullName: String {
"\(folderName)/\(fileName)"
}
var onThumbnailSet: ((UIImage?) -> Void)
private var folderName: String
private var fileName: String
private var cancelableThumbnail: AnyCancellable?
private var thumbnailUrl: URL? {
return URL(string: "\(BASE_URL)/thumbnail/\(fullName)")
}
private var downloadUrl: URL? {
return URL(string: "\(BASE_URL)/download/\(fullName)")
}
init(folderName: String, fileName: String) {
self.folderName = folderName
self.fileName = fileName
self.loaded = false
self.loadThumbnail()
}
private func loadThumbnail() {
guard let requestUrl = thumbnailUrl else { fatalError() }
self.cancelableThumbnail = URLSession.shared.dataTaskPublisher(for: requestUrl)
.subscribe(on: Self.sessionProcessingQueue)
.map { UIImage(data: $0.data) }
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { (suscriberCompletion) in
switch suscriberCompletion {
case .finished:
break
case .failure(let error):
print(error.localizedDescription)
}
}, receiveValue: { [weak self] (value) in
self?.objectWillChange.send()
self?.loaded.toggle()
self?.thumbnail = value
})
}
The view :
struct MyView: View {
#ObservedObject var imagesHolder: ImagesHolder = ImagesHolder()
var body: some View {
VStack {
Button(action: {
self.loadImages()
}, label: {
Text("Load images")
})
ForEach(imagesHolder.images, id: \.self) { image in
if image.loaded {
Image(uiImage: image.thumbnail!)
.frame(width: 600, height: 600)
} else {
Text("Not loaded")
}
}
}
}
private func loadImages() -> Void {
loadMediaList(
onLoadDone: { myImages in
myImages.forEach { image in
imagesHolder.append(image)
}
}
)
}
}
The observed object containing the array of loaded images :
class ImagesHolder: ObservableObject {
#Published var images: [MyImage] = []
func append(_ myImage: MyImage) {
objectWillChange.send()
images.append(myImage)
}
}
And finally my data loader :
func loadMediaList(onLoadDone: #escaping (([MyImage]) -> Void)) -> AnyCancellable {
let url = URL(string: "\(BASE_URL)/medias")
guard let requestUrl = url else { fatalError() }
return URLSession.shared.dataTaskPublisher(for: requestUrl)
.subscribe(on: Self.sessionProcessingQueue)
.map { parseJSON(data: $0.data) }
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { (suscriberCompletion) in
switch suscriberCompletion {
case .finished:
break
case .failure(let error):
print(error.localizedDescription)
}
}, receiveValue: { images in
onLoadDone(images);
})
}
What I ended up doing and worked great for me was having a seperate view for the display of my Image like this :
struct MyImageView: View {
#ObservedObject var image: MyImage
init(image: MyImage) {
self.image = image
}
var body: some View {
if image.loaded {
Image(uiImage: image.thumbnail!)
.resizable()
} else {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
.frame(width: 100, height: 100, alignment: .center)
}
}
}
struct MyView: View {
#ObservedObject var imagesHolder: ImagesHolder = ImagesHolder()
var body: some View {
VStack {
Button(action: {
self.loadImages()
}, label: {
Text("Load images")
})
ForEach(imagesHolder.images, id: \.self) { image in
MyImageView(image: image)
}
}
}
private func loadImages() -> Void {
loadMediaList(
onLoadDone: { myImages in
myImages.forEach { image in
imagesHolder.append(image)
}
}
)
}
}