-1

I have a simple SwiftUI application with CoreData and two views. One view displays all "Place" objects. You can create new places and you can show the details for the place. Inside the second view you can add "PlaceItem"s to a place.

The problem is that, once a new "PlaceItem" is added to the viewContext, the @NSFetchRequest seems to forget about its additional predicates, which I set in onAppear. Then every place item is shown inside the details view. Once I update the predicate manually (the refresh button), only the items from the selected place are visible again.

Any idea how this can be fixed? Here's the code for my two views:

struct PlaceView: View {
    @FetchRequest(sortDescriptors: []) private var places: FetchedResults<Place>
    @Environment(\.managedObjectContext) private var viewContext


    var body: some View {
        NavigationView {
            List(places) { place in
                NavigationLink {
                    PlaceItemsView(place: place)
                } label: {
                    Text(place.name ?? "")
                }
                
            }
        }
        .toolbar {
            ToolbarItem(placement: .primaryAction) {
                Button {
                    let place = Place(context: viewContext)
                    place.name = NSUUID().uuidString
                    try! viewContext.save()
                } label: {
                    Label("Add", systemImage: "plus")
                }

            }
        }
        .navigationTitle("Places")
    }
}

struct PlaceItemsView: View {
    @ObservedObject var place: Place
    @State var searchText = ""

    @FetchRequest(sortDescriptors: []) private var items: FetchedResults<PlaceItem>
    @Environment(\.managedObjectContext) private var viewContext

    func updatePredicate() {
        var predicates = [NSPredicate]()
        predicates.append(NSPredicate(format: "place == %@", place))
        if !searchText.isEmpty {
            predicates.append(NSPredicate(format: "name CONTAINS %@", searchText))
        }

        items.nsPredicate = NSCompoundPredicate(type: .and, subpredicates: predicates)
    }

    var body: some View {
        NavigationView {
            List(items) { item in
                Text(item.name ?? "");
            }
        }
        .onAppear(perform: updatePredicate)
        .toolbar {
            ToolbarItem(placement: .primaryAction) {
                Button {
                    let item = PlaceItem(context: viewContext)
                    item.place = place
                    item.name = NSUUID().uuidString
                    try! viewContext.save()
                } label: {
                    Label("Add", systemImage: "plus")
                }
            }

            ToolbarItem(placement: .navigationBarLeading) {
                Button(action: updatePredicate) {
                    Label("Refresh", systemImage: "arrow.clockwise")
                }
            }
        }
        .searchable(
            text: $searchText,
            placement: .navigationBarDrawer(displayMode: .always),
            prompt: "Search or add articles …"
        )
        .onAppear(perform: updatePredicate)
        .onChange(of: searchText, perform: { _ in
            updatePredicate()
        })
        .navigationTitle(place.name ?? "")
    }
}

struct ContentView: View {
    @Environment(\.managedObjectContext) private var viewContext

    var body: some View {
        NavigationView {
            PlaceView()
        }
    }
}

Thanks!

Tobias Tom
  • 43
  • 5

3 Answers3

1

Rather than relying on onAppear, you might have more luck defining your fetchRequest in PlaceItemView’s initializer, e.g.:

struct PlaceItemsView: View {
  @ObservedObject private var place: Place
  @FetchRequest private var items: FetchedResults<PlaceItem>

  init(place: Place) {
    self.place = place
    self._items = FetchRequest(
      entity: PlaceItem.entity(),
      sortDescriptors: [],
      predicate: NSPredicate(format: "place == %@", place)
    )
  }
ScottM
  • 7,108
  • 1
  • 25
  • 42
  • You are absolutely right, this works. Unfortunately my real use case is a little more complex. I've updated the sample code to reflect this. – Tobias Tom Feb 04 '22 at 16:36
  • `.onAppear()` is an unreliable place to put your `updatePredicate` call, and frankly, the `updatePredicate` function should be in your view model, and let that handle the logic. When you try to do it all in the view, you run into dead ends like this. – Yrb Feb 04 '22 at 16:56
  • While I might agree that a view model might be better suited, `@FetchRequest` has the ability to be modified, that's what I wanted to try here. – Tobias Tom Feb 04 '22 at 19:23
  • A simpler way is just to pass the FetchRequest into a child View, see my answer. – malhal Feb 10 '22 at 23:45
1

It's best to break Views up by the data they need, its called having a tighter invalidation and solves your problem of updating the fetch request too! e.g.

struct PlaceItemsView {
     @FetchRequest private var items: FetchedResults<PlaceItem>

    var body: some View {
        List(items) { item in
            Text(item.name ?? "")
        }
    }
}

// body called when either searchText is different or its a different place (not when the place's properties change because no need to)
struct SearchPlaceView {
    let searchText: String
    let place: Place

    var predicate: NSPredicate {
        var predicates = [NSPredicate]()
        predicates.append(NSPredicate(format: "place == %@", place))
        if !searchText.isEmpty {
            predicates.append(NSPredicate(format: "name CONTAINS %@", searchText))
        }
        return NSCompoundPredicate(type: .and, subpredicates: predicates)
    }

    var body: some View {
         PlaceItemsView(items:FetchRequest<PlaceItem>(sortDescriptors:[], predicate:predicate))
    }
}

// body called when either the place has a change or search text changes
struct PlaceItemsView: View {
    @Environment(\.managedObjectContext) private var viewContext
    @ObservedObject var place: Place
    @State var searchText = ""
   
    var body: some View {
        NavigationView {
            SearchPlaceView(searchTest:searchText, place: place)
        }
        .toolbar {
            ToolbarItem(placement: .primaryAction) {
                Button {
                    let item = PlaceItem(context: viewContext)
                    item.place = place
                    item.name = NSUUID().uuidString
                    try! viewContext.save()
                } label: {
                    Label("Add", systemImage: "plus")
                }
            }

            ToolbarItem(placement: .navigationBarLeading) {
                Button(action: updatePredicate) {
                    Label("Refresh", systemImage: "arrow.clockwise")
                }
            }
        }
        .searchable(
            text: $searchText,
            placement: .navigationBarDrawer(displayMode: .always),
            prompt: "Search or add articles …"
        )
        .navigationTitle(place.name ?? "")
    }
}

Always try to break the Views up by the smallest data their body needs, try to not think about breaking them up by screens or areas of a screen which is a very common mistake.

malhal
  • 26,330
  • 7
  • 115
  • 133
  • 1
    I never thought about providing the fetch request as a parameter. I like this approach. Also, thank you for the tip about breaking up views based on their data. I think I'll need some time to get used to it, but it totally makes sense. – Tobias Tom Feb 14 '22 at 14:31
0

import UIKit import CoreData

class LoginViewController: UIViewController, UITableViewDataSource, UITableViewDelegate{

//MARK: - Outlets

@IBOutlet weak var LogInDataTableView: UITableView!

@IBOutlet weak var btnLogIn: UIButton!
@IBOutlet weak var btnAdd: UIButton!


//MARK: - Variables

var fetchedCoreData = [NSManagedObject]()

let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
var name = String()

let placeHolderArray = ["username", "password"]
var isLogin = false

//MARK: - Methods and other functionalities

override func viewDidLoad() {
    super.viewDidLoad()
    
    btnLogIn.layer.cornerRadius = 20.0
    LogInDataTableView.layer.cornerRadius = 20.0
    LogInDataTableView.layer.borderWidth = 2.0
    LogInDataTableView.layer.borderColor = UIColor.blue.cgColor
    let tap = UITapGestureRecognizer(target: self, action: #selector(UIInputViewController.dismissKeyboard))
    view.addGestureRecognizer(tap)
}

//MARK: - dismiss keyboard when tap on screen

@objc func dismissKeyboard() {
    //Causes the view (or one of its embedded text fields) to resign the first responder status.
    view.endEditing(true)
}

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    self.navigationController?.isNavigationBarHidden = true
    
    fetchRequest()
}

//MARK: - fetch saved data from coredata

func fetchRequest() {
    let request = NSFetchRequest<NSFetchRequestResult>(entityName: "EntityName")
    request.returnsObjectsAsFaults = false
    
    do {
        
        let result = try context.fetch(request)
        fetchedCoreData = result as! [NSManagedObject]
    } catch {
        print("error while fetch data from database")
    }
}

//MARK: - login button click with validation

@IBAction func btnLogInClick(_ sender: Any) {
    if loginData[0].isEmpty {
        let alert = UIAlertController(title: "Alert", message: "Please enter username", preferredStyle: .alert)
        let okAction = UIAlertAction(title: "OK", style: .default, handler: nil)
        alert.addAction(okAction)
        present(alert, animated: true, completion: nil)
    } else if loginData[1].isEmpty {
        let alert = UIAlertController(title: "Alert", message: "Please enter password", preferredStyle: .alert)
        let okAction = UIAlertAction(title: "OK", style: .default, handler: nil)
        alert.addAction(okAction)
        present(alert, animated: true, completion: nil)
    } else if  loginData[2].isEmpty{
        let alert = UIAlertController(title: "Alert", message: "Please enter id", preferredStyle: .alert)
        let okAction = UIAlertAction(title: "OK", style: .default, handler: nil)
        alert.addAction(okAction)
        present(alert, animated: true, completion: nil)
    } else {
        
        let registeredUser = clinicData.contains { objc in
            objc.value(forKey: "username") as! String == loginData[0] && objc.value(forKey: "password") as! String == loginData[1]
        }
        
        if registeredUser == true {
            for data in clinicData {
                if data.value(forKey: "username") as! String == loginData[0] && data.value(forKey: "password") as! String == loginData[1] {
                    clinicName = data.value(forKey: "clinic") as! String
                    let addClinicVC = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "HomeScreenViewController") as! HomeScreenViewController
                    addClinicVC.clinicName = clinicName
                    self.navigationController?.pushViewController(addClinicVC, animated: true)
                }
            }
            
        } else {
            //MARK: - alert
            let alert = UIAlertController(title: "Alert", message: "username and password mismatch", preferredStyle: .alert)
            let okAction = UIAlertAction(title: "OK", style: .default, handler: nil)
            alert.addAction(okAction)
            present(alert, animated: true, completion: nil)
        }
        
        
    }
    
}

@IBAction func didTapAddClinic(_ sender: UIButton) {
    let addVC = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "NewScreenViewController") as! NewScreenViewController
    self.navigationController?.pushViewController(addVC, animated: true)
}

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return placeHolderArray.count
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "LoginTableViewCell", for: indexPath) as! LoginTableViewCell
    cell.txtLoginData.tag = indexPath.row
    cell.txtLoginData.placeholder = placeHolderArray[indexPath.row]
    return cell
}

func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
    return 60
}

}