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()
}
}
}
Related
I have another question.
I have a demo app where I add a ToDo in a Firestore database. From the base View I open a .sheet with a TextEditor where I enter data and save it into Firestore database. But on dismiss the List of ToDos in the base View is gone and is not refreshed until I go to another tab in the app and return back.
I have a ViewModel where I use a Firebase snapshot listener.
Code of the base View:
import Firebase
import Foundation
import SwiftUI
import FirebaseStorage
struct HomeMenuView: View {
#ObservedObject var toDosViewModel = ToDosViewModel()
#Binding var showAddToDoView: Bool
#State private var showModifyToDoView = false
#State private var note = ""
#State private var selectedToDoId = ""
func removeRow(at offset:IndexSet) {
for index in offset {
toDosViewModel.deleteNote(noteToDelete: toDosViewModel.todos[index].id!)
}
}
var body: some View {
ZStack{
VStack (alignment: .center){
List() {
ForEach(toDosViewModel.todos) { todo in
VStack(alignment: .leading, spacing: 10) {
Text(todo.notes)
.font(.subheadline)
.foregroundColor(Color.tabBarColor)
.lineLimit(2)
.onTapGesture {
showAddToDoView = true
selectedToDoId = todo.id!
note = todo.notes
}
}
.listRowSeparatorTint(Color.tabBarColor)
}
.onDelete(perform: removeRow)
}
.listStyle(InsetGroupedListStyle())
.onAppear() {
toDosViewModel.subscribe()
}
}
}
.sheet(isPresented: $showAddToDoView) {
VStack() {
HStack () {
Button("Save") {
guard !note.isEmpty else
{ showAddToDoView = false; return }
toDosViewModel.addNote(notes: note)
note = ""
showAddToDoView = false
}
.offset(x: 20)
Spacer()
Button("Back") {
note = ""
showAddToDoView = false
}
.offset(x: -20)
}
.frame(height: 50, alignment: .center)
TextEditor(
text: $note
)
}
}
}
}
The ViewModel:
import Foundation
import FirebaseFirestore
import FirebaseFirestoreSwift
import UIKit
class ToDosViewModel: ObservableObject {
#Published var todos = [ToDo]()
#Published var errorMessage: String?
private var db = Firestore.firestore()
private var listenerRegistration: ListenerRegistration?
func subscribe() {
if listenerRegistration == nil {
listenerRegistration = db.collection("todos")
.order(by: "timestamp", descending: true)
.addSnapshotListener { [weak self] (querySnapshot, error) in
guard let documents = querySnapshot?.documents else {
print("No documents")
return
}
self?.todos = documents.compactMap { queryDocumentSnapshot in
let result = Result { try queryDocumentSnapshot.data(as: ToDo.self) }
switch result {
case .success(let todo):
if let todo = todo {
self?.errorMessage = nil
return todo
}
else {
self?.errorMessage = "Document doesn't exist."
return nil
}
case .failure(let error):
switch error {
case DecodingError.typeMismatch(_, let context):
self?.errorMessage = "\(error.localizedDescription): \(context.debugDescription)"
case DecodingError.valueNotFound(_, let context):
self?.errorMessage = "\(error.localizedDescription): \(context.debugDescription)"
case DecodingError.keyNotFound(_, let context):
self?.errorMessage = "\(error.localizedDescription): \(context.debugDescription)"
case DecodingError.dataCorrupted(let key):
self?.errorMessage = "\(error.localizedDescription): \(key)"
default:
self?.errorMessage = "Error decoding document: \(error.localizedDescription)"
}
return nil
}
}
}
}
}
func addNote(notes: String) {
db.collection("todos").document().setData(["notes" : notes, "timestamp" : FieldValue.serverTimestamp()])
}
func modifyNote(noteToModify: String, notes: String) {
db.collection("todos").document(noteToModify).setData(["notes" : notes, "timestamp" : FieldValue.serverTimestamp()])
}
func deleteNote(noteToDelete: String) {
db.collection("todos").document(noteToDelete).delete()
}
}
Any idea what the issue could be?
Thanks a lot for your support.
Change
#ObservedObject var toDosViewModel = ToDosViewModel()
To
#StateObject var toDosViewModel = ToDosViewModel()
When I run my app and try swiping, the onDelete does not appear and doesn't work. I haven't had the chance to really test if it deletes or not because when I swipe it doesn't allow me to try deleting it. I am using RealmSwift and posted the code for the view as well as the ViewModel I use. Sorry if this isn't enough code, let me know and I'll link my GitHub repo, or share more code.
import SwiftUI
import RealmSwift
import Combine
enum ActiveAlert{
case error, noSauce
}
struct DoujinView: View {
#ObservedObject var doujin: DoujinAPI
// #ObservedResults(DoujinInfo.self) var doujinshis
#State private var detailViewShowing: Bool = false
#State private var selectedDoujin: DoujinInfo?
#StateObject var doujinModel = DoujinInfoViewModel()
var body: some View {
//Code if there are any Doujins
ScrollView(.vertical) {
LazyVStack(spacing: 0) {
ForEach(doujinModel.doujins, id: \.UniqueID) { doujinshi in
Button(action: {
self.detailViewShowing = true
self.doujinModel.selectedDoujin = doujinshi
}) {
DoujinCell(image: convertBase64ToImage(doujinshi.PictureString))
}
}
.onDelete(perform: { indexSet in
self.doujinModel.easyDelete(at: indexSet)
})
//This will preseent the sheet that displays information for the doujin
.sheet(isPresented: $detailViewShowing, onDismiss: {if doujinModel.deleting == true {doujinModel.deleteDoujin()}}, content: {
DoujinInformation(theAPI: doujin, doujinModel: doujinModel)
})
// Loading circle
if doujin.loadingCircle == true{
LoadingCircle(theApi: doujin)
}
}
}
}
}
enum colorSquare:Identifiable{
var id: Int{
hashValue
}
case green
case yellow
case red
}
class DoujinInfoViewModel: ObservableObject{
var theDoujin:DoujinInfo? = nil
var realm:Realm?
var token: NotificationToken? = nil
#ObservedResults(DoujinInfo.self) var doujins
#Published var deleting:Bool = false
#Published var selectedDoujin:DoujinInfo? = nil
#Published var loading:Bool = false
init(){
let realm = try? Realm()
self.realm = realm
token = doujins.observe({ (changes) in
switch changes{
case .error(_):break
case .initial(_): break
case .update(_, deletions: _, insertions: _, modifications: _):
self.objectWillChange.send() }
})
}
deinit {
token?.invalidate()
}
var name: String{
get{
selectedDoujin!.Name
}
}
var id: String {
get {
selectedDoujin!.Id
}
}
var mediaID:String {
get {
selectedDoujin!.MediaID
}
}
var numPages:Int{
get {
selectedDoujin!.NumPages
}
}
var pictureString:String {
get {
selectedDoujin!.PictureString
}
}
var uniqueId: String{
get{
selectedDoujin!.PictureString
}
}
var similarity:Double{
get {
selectedDoujin!.similarity
}
}
var color:colorSquare{
get{
switch selectedDoujin!.similarity{
case 0...50:
return .red
case 50...75:
return .yellow
case 75...100:
return .green
default:
return .green
}
}
}
var doujinTags: List<DoujinTags>{
get {
selectedDoujin!.Tags
}
}
func deleteDoujin(){
try? realm?.write{
realm?.delete(selectedDoujin!)
}
deleting = false
}
func easyDelete(at indexSet: IndexSet){
if let index = indexSet.first{
let realm = doujins[indexSet.first!].realm
try? realm?.write({
realm?.delete(doujins[indexSet.first!])
})
}
}
func addDoujin(theDoujin: DoujinInfo){
try? realm?.write({
realm?.add(theDoujin)
})
}
}
.onDelete works only for List. For LazyVStack we need to create our own swipe to delete action.
Here is the sample demo. You can modify it as needed.
SwipeDeleteRow View
struct SwipeDeleteRow<Content: View>: View {
private let content: () -> Content
private let deleteAction: () -> ()
private var isSelected: Bool
#Binding private var selectedIndex: Int
private var index: Int
init(isSelected: Bool, selectedIndex: Binding<Int>, index: Int, #ViewBuilder content: #escaping () -> Content, onDelete: #escaping () -> Void) {
self.isSelected = isSelected
self._selectedIndex = selectedIndex
self.content = content
self.deleteAction = onDelete
self.index = index
}
#State private var offset = CGSize.zero
#State private var offsetY : CGFloat = 0
#State private var scale : CGFloat = 0.5
var body : some View {
HStack(spacing: 0){
content()
.frame(width : UIScreen.main.bounds.width, alignment: .leading)
Button(action: deleteAction) {
Image("delete")
.renderingMode(.original)
.scaleEffect(scale)
}
}
.background(Color.white)
.offset(x: 20, y: 0)
.offset(isSelected ? self.offset : .zero)
.animation(.spring())
.gesture(DragGesture(minimumDistance: 30, coordinateSpace: .local)
.onChanged { gestrue in
self.offset.width = gestrue.translation.width
print(offset)
}
.onEnded { _ in
self.selectedIndex = index
if self.offset.width < -50 {
self.scale = 1
self.offset.width = -60
self.offsetY = -20
} else {
self.scale = 0.5
self.offset = .zero
self.offsetY = 0
}
}
)
}
}
Demo View
struct Model: Identifiable {
var id = UUID()
}
struct CustomSwipeDemo: View {
#State var arr: [Model] = [.init(), .init(), .init(), .init(), .init(), .init(), .init(), .init()]
#State private var listCellIndex: Int = 0
var body: some View {
ScrollView(.vertical) {
LazyVStack(spacing: 0) {
ForEach(arr.indices, id: \.self) { index in
SwipeDeleteRow(isSelected: index == listCellIndex, selectedIndex: $listCellIndex, index: index) {
if let item = self.arr[safe: index] {
Text(item.id.description)
}
} onDelete: {
arr.remove(at: index)
self.listCellIndex = -1
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
}
}
}
}
}
Helper function
//Help to preventing delete row from index out of bounds.
extension Collection where Indices.Iterator.Element == Index {
subscript (safe index: Index) -> Iterator.Element? {
return indices.contains(index) ? self[index] : nil
}
}
I have SwiftUI view which I want to change after checking user log-pass. I'm trying to change isAuth var like you can see below.
import SwiftUI
struct Auth : View, AuthProtocol {
#State private var isAuth = false
init() {
userManager.notifier = self
}
var body : some View {
if isAuth {
WelcomeView()
} else {
VStack {
Divider()
Text("Please, wait a minute...")
Divider()
}
.frame(width: 450, height: 350)
}
}
func passAuth() {
if userManager.validateUser() {
self.isAuth.toggle()
print("isAuth: \(isAuth)")
}
}
}
And I got output isAuth: false.
I call passAuth func from this code
class <classname> {
var notifier : AuthProtocol!
func fetchUsers() {
db.collection("users").getDocuments { (querySnapshot, err) in
if let err = err {
print("Error getting documents: \(err)")
} else {
for document in querySnapshot!.documents {
var newUser = UserData()
newUser.login = document["login"] as! String
newUser.name = document["name"] as! String
newUser.password = document["password"] as! String
newUser.accessLevel = document["access_level"] as! Int
self.usersList.append(newUser)
}
self.notifier.passAuth()
}
}
}
}
I have no idea why value of isAuth isn't changing...
We use MVVM in swiftui. so we need a viewModel. now when the network request resutl will come we can update our view model and the change will be reflected by the View, simple right? i advice you to check out swiftui tutorials.
struct Auth : View {
#StateObject var viewModel = ViewModel()
// #Binding var isAuth: Bool // should use binding is isAuth will be passed by a parent view
var body : some View {
if viewModel.isAuth {
Text("WelcomeView()")
} else {
VStack {
Divider()
Text("Please, wait a minute...")
Divider()
}
.frame(width: 450, height: 350)
}
}
}
class ViewModel : ObservableObject {
#Published var isAuth = false
func fetchUsers() {
db.collection("users").getDocuments { (querySnapshot, err) in
if let err = err {
print("Error getting documents: \(err)")
} else {
for document in querySnapshot!.documents {
var newUser = UserData()
newUser.login = document["login"] as! String
newUser.name = document["name"] as! String
newUser.password = document["password"] as! String
newUser.accessLevel = document["access_level"] as! Int
self.usersList.append(newUser)
}
//
if validateUser() {
self.isAuth = true
}
//
}
}
}
}
I want to write a ToDoList in swiftUI with core data. Everything works so far but I want to have a checkbox next to each item it Signify whether it is completed or not.
I have added a property isChecked:boolean in core data but I don't know how to properly read it from the database. How to use a Toggle() in my case?
struct ContentView: View {
#Environment(\.managedObjectContext) var context
#FetchRequest(fetchRequest: ToDoListItem.getAllToDoListItems())
var items: FetchedResults<ToDoListItem>
#State var text: String = ""
var body: some View {
NavigationView {
List {
Section (header: Text("NewItem")){
HStack {
TextField("Enter new Item.",text: $text)
Button(action: {
if !text.isEmpty{
let newItem = ToDoListItem(context: context)
newItem.name = text
newItem.createdAt = Date()
// current date as created
newItem.isChecked = false
do {
try context.save()
} catch {
print(error)
}
// to clear the textField from the previous entry
text = ""
}
}, label: {
Text("Save")
})
}// end of vstack
}
Section {
ForEach(items){ toDoListItem in
VStack(alignment: .leading){
// to have a checkbox
Button {
toDoListItem.isChecked.toggle()
} label: {
Label(toDoListItem.name!, systemImage: toDoListItem.isChecked ? "checkbox.square" : "square")
}
if let name = toDoListItem.name {
// Toggle(isOn: toDoListItem.isChecked)
Text(name)
.font(.headline)
}
//Text(toDoListItem.name!)
//.font(.headline)
if let createdAt = toDoListItem.createdAt {
//Text("\(toDoListItem.createdAt!)")
Text("\(createdAt)")
}
}
}.onDelete(perform: { indexSet in
guard let index = indexSet.first else {
return
}
let itemToDelete = items[index]
context.delete(itemToDelete)
do {
try context.save()
}
catch {
print(error)
}
})
}
}
.navigationTitle("To Do List")
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
ToDoListItem.swift
class ToDoListItem: NSManagedObject,Identifiable {
#NSManaged var name:String?
#NSManaged var createdAt:Date?
#NSManaged var isChecked:Bool
// mapped to the entry properties in database
}
extension ToDoListItem {
static func getAllToDoListItems() -> NSFetchRequest<ToDoListItem>{
let request:NSFetchRequest<ToDoListItem> = ToDoListItem.fetchRequest() as!
NSFetchRequest<ToDoListItem>
// cast as todolist item
let sort = NSSortDescriptor(key: "createdAt", ascending: true)
// above order of sorting
request.sortDescriptors = [sort]
return request
}
}
Should isChecked be an optional as well?
There is a ListView. I make a transaction in Cloud Firestore by changing the field of an element when I click on it in the list. Data in the database changes as it should, but after this action all the elements in the list disappear (although there is .onAppear {fetchData}). An important point: this is a child view, there is no such problem in the parent view.
I also added a button at the bottom of the list to execute fetchData (), when I click on it, the data returns to the list
What could be the problem? Thanks
import SwiftUI
struct SecondView: View {
#ObservedObject var viewModel = BooksViewModel()
var body: some View {
VStack {
List(viewModel.books) { book in
VStack(alignment: .leading) {
Button("Update data"){
let updBook = book
self.viewModel.myTransaction(book: updBook)
}
Text(book.title)
.font(.headline)
Text(book.author)
.font(.subheadline)
Text("\(book.numberOfPages) pages")
.font(.subheadline)
}
}
.navigationBarTitle("Books")
.onAppear() {
self.viewModel.fetchData()
}
Button("update list"){
self.viewModel.fetchData()
}
}
}
}
ViewModel:
import Foundation
import FirebaseFirestore
import FirebaseFirestoreSwift
class BooksViewModel: ObservableObject {
#Published var books = [Book]()
private var db = Firestore.firestore()
func fetchData() {
db.collection("books").addSnapshotListener { (querySnapshot, error) in
guard let documents = querySnapshot?.documents else {
print("No documents")
return
}
self.books = documents.compactMap { queryDocumentSnapshot -> Book? in
return try? queryDocumentSnapshot.data(as: Book.self)
}
}
}
func deleteBook(book: Book){
if let bookID = book.id{
db.collection("books").document(bookID).delete()
}
}
func updateBook(book: Book) {
if let bookID = book.id{
do {
try db.collection("books").document(bookID).setData(from: book) }
catch {
print(error)
}
}
}
func addBook(book: Book) {
do {
let _ = try db.collection("books").addDocument(from: book)
}
catch {
print(error)
}
}
func myTransaction(book: Book){
let bookID = book.id
let targetReference = db.collection("books").document(bookID!)
db.runTransaction({ (transaction, errorPointer) -> Any? in
let targetDocument: DocumentSnapshot
do {
try targetDocument = transaction.getDocument(targetReference)
} catch let fetchError as NSError {
errorPointer?.pointee = fetchError
return nil
}
guard let oldValue = targetDocument.data()?["pages"] as? Int else {
let error = NSError(
domain: "AppErrorDomain",
code: -1,
userInfo: [
NSLocalizedDescriptionKey: "Unable to retrieve population from snapshot \(targetDocument)"
]
)
errorPointer?.pointee = error
return nil
}
// Note: this could be done without a transaction
// by updating the population using FieldValue.increment()
transaction.updateData(["pages": oldValue + 1], forDocument: targetReference)
return nil
}) { (object, error) in
if let error = error {
print("Transaction failed: \(error)")
} else {
print("Transaction successfully committed!")
}
}
}
}
Parent view:
import SwiftUI
struct ContentView: View {
#ObservedObject var viewModel = BooksViewModel()
var body: some View {
NavigationView {
VStack {
List(viewModel.books) { book in
VStack(alignment: .leading) {
Button("Update"){
let delBook = book
self.viewModel.myTransaction(book: delBook)
}
Text(book.title)
.font(.headline)
Text(book.author)
.font(.subheadline)
Text("\(book.numberOfPages) pages")
.font(.subheadline)
}
}
.navigationBarTitle("Books")
.onAppear() {
self.viewModel.fetchData()
}
NavigationLink(destination: SecondView()){
Text("Second View")
}
}
}
}
}
A possible solution might be that your Views and its ViewModels interfere with each other. It looks like you create two instances of the same BookViewModel:
struct ContentView: View {
#ObservedObject var viewModel = BooksViewModel()
struct SecondView: View {
#ObservedObject var viewModel = BooksViewModel()
Try creating one BooksViewModel and pass it between views (you can use an #EnvironmentObject).