SwiftUI View and @FetchRequest predicate with variable that can change
Asked Answered
W

7

40

I have a view showing messages in a team that are filtered using @Fetchrequest with a fixed predicate 'Developers'.

struct ChatView: View {

@FetchRequest(
    sortDescriptors: [NSSortDescriptor(keyPath: \Message.createdAt, ascending: true)],
    predicate: NSPredicate(format: "team.name == %@", "Developers"),
    animation: .default) var messages: FetchedResults<Message>

@Environment(\.managedObjectContext)
var viewContext

var body: some View {
    VStack {
        List {
            ForEach(messages, id: \.self) { message in
                VStack(alignment: .leading, spacing: 0) {
                    Text(message.text ?? "Message text Error")
                    Text("Team \(message.team?.name ?? "Team Name Error")").font(.footnote)
                }
            }...

I want to make this predicate dynamic so that when the user switches team the messages of that team are shown. The code below gives me the following error

Cannot use instance member 'teamName' within property initializer; property initializers run before 'self' is available

struct ChatView: View {

@Binding var teamName: String

@FetchRequest(
    sortDescriptors: [NSSortDescriptor(keyPath: \Message.createdAt, ascending: true)],
    predicate: NSPredicate(format: "team.name == %@", teamName),
    animation: .default) var messages: FetchedResults<Message>

@Environment(\.managedObjectContext)
var viewContext

...

I can use some help with this, so far I'm not able to figure this out on my own.

Wayne answered 10/9, 2019 at 12:43 Comment(0)
S
40

had the same problem, and a comment of Brad Dillon showed the solution:

var predicate:String
var wordsRequest : FetchRequest<Word>
var words : FetchedResults<Word>{wordsRequest.wrappedValue}

    init(predicate:String){
        self.predicate = predicate
        self.wordsRequest = FetchRequest(entity: Word.entity(), sortDescriptors: [], predicate:
            NSPredicate(format: "%K == %@", #keyPath(Word.character),predicate))

    }

in this example, you can modify the predicate in the initializer.

Scandium answered 24/9, 2019 at 13:20 Comment(3)
Can verify this solution works. It takes a little bit of doing on your end (I had to pass through strings of user entered data through the Struct call), but almost verbatim this code will plug in with very little, if any, modifications. When you're looping out the results, just use the FetchRequest var (wordsRequest in this instance) like any other ForEach and pull the entity attributes you want. btw, I'm using this solution with multiple entities and creating a view for each entity. Then I call the structs on a default view and pass the necessary vars to get the Core Data from all.Embroidery
Doesn't this cause a new fetch to be executed every single time any state changes?Robin
this ain't gonna work if you want to receive updates as you get with @FetchRequest when entity data changesHormuz
R
32

Edit 9/10/2023: I have a new answer that is simpler because does not require the extra View and the predicate is still not lost even if the View containing the fetch is re-init, e.g.

struct MessageView: View {
    let team: Team // or @ObservedObject if you want to show its properties.

    private var request = FetchRequest<Message>(
        sortDescriptors: [SortDescriptor(\.createdAt)],
        predicate: NSPredicate(value: false),
        animation: .default)

    private var messages: FetchedResults<Message> {
         request.wrappedValue.nsPredicate = NSPredicate(format: "team = %@", team) // setting the same predicate again does not seem to cause a refetch (according to SQL debug output)
         // here you could also update the sort from a state.
        return request.wrappedValue
    }

    ...

This pattern of splitting the fetch and results is actually documented in the header. However applying a predicate is not documented, but it is non-mutating so should be acceptable.

Old answer:

With SwiftUI it is important that the View struct does not appear to be changed otherwise body will be called needlessly which in the case of @FetchRequest also hits the database. SwiftUI checks for changes in View structs simply using equality and calls body if not equal, i.e. if any property has changed. On iOS 14, even if @FetchRequest is recreated with the same parameters, it results in a View struct that is different thus fails SwiftUI's equality check and causes the body to be recomputed when normally it wouldn’t be. @AppStorage and @SceneStorage also have this problem so I find it strange that @State which most people probably learn first does not! Anyway, we can workaround this with a wrapper View with properties that do not change, which can stop SwiftUI's diffing algorithm in its tracks:

struct ContentView: View {
    @State var teamName "Team" // source of truth, could also be @AppStorage if would like it to persist between app launches.
    @State var counter = 0
    var body: some View {
        VStack {
            ChatView(teamName:teamName) // its body will only run if teamName is different, so not if counter being changed was the reason for this body to be called.
            Text("Count \(counter)")
        }
    }
}

struct ChatView: View {
    let teamName: String
    var body: some View {
        // ChatList body will be called every time but this ChatView body is only run when there is a new teamName so that's ok.
        ChatList(messages: FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \Message.createdAt, ascending: true)], predicate: NSPredicate(format: "team.name = %@", teamName)))
    }
}

struct ChatList : View {
    @FetchRequest var messages: FetchedResults<Message>
    var body: some View {
        ForEach(messages) { message in
             Text("Message at \(message.createdAt!, formatter: messageFormatter)")
        }
    }
}

Edit: it might be possible to achieve the same thing using EquatableView instead of the wrapper View to allow SwiftUI to do its diffing on the teamName only and not the FetchRequest var. More info here: https://swiftwithmajid.com/2020/01/22/optimizing-views-in-swiftui-using-equatableview/

Robin answered 4/10, 2020 at 21:38 Comment(4)
This seems like the most idiomatic solution.Thyroxine
this is it. Should be the answer.Pallaton
This answer fulfils the recommended structure (by Apple) to break up SwiftUI views into many small components. In doing so, this answer also breaks out the properties that are used in the SwiftUI diffing algorithm (as noted in the article by Majid) and therefore the fetch request calls are minimised. I applied this structure to one of my lists (with @SectionedFetchRequest) and noted faster rendering and also eliminated the problems I was experiencing with the .searchable modifier.Sulfur
I would like to provide some tips for this. FetchRequest should be created with this parameter: "entity: NSEntityDescription.entity(forEntityName: "Your Entity", in: managedObjectContext)", otherwise some errors would come up.Wye
E
13

May be a more general solution for dynamically filtering @FetchRequest.

1、Create custom DynamicFetchView

import CoreData
import SwiftUI

struct DynamicFetchView<T: NSManagedObject, Content: View>: View {
    let fetchRequest: FetchRequest<T>
    let content: (FetchedResults<T>) -> Content

    var body: some View {
        self.content(fetchRequest.wrappedValue)
    }

    init(predicate: NSPredicate?, sortDescriptors: [NSSortDescriptor], @ViewBuilder content: @escaping (FetchedResults<T>) -> Content) {
        fetchRequest = FetchRequest<T>(entity: T.entity(), sortDescriptors: sortDescriptors, predicate: predicate)
        self.content = content
    }

    init(fetchRequest: NSFetchRequest<T>, @ViewBuilder content: @escaping (FetchedResults<T>) -> Content) {
        self.fetchRequest = FetchRequest<T>(fetchRequest: fetchRequest)
        self.content = content
    }
}

2、how to use

//our managed object
public class Event: NSManagedObject{
    @NSManaged public var status: String?
    @NSManaged public var createTime: Date?
    ... ...
}

// some view

struct DynamicFetchViewExample: View {
    @State var status: String = "undo"

    var body: some View {
        VStack {
            Button(action: {
                self.status = self.status == "done" ? "undo" : "done"
            }) {
                Text("change status")
                    .padding()
            }

            // use like this
            DynamicFetchView(predicate: NSPredicate(format: "status==%@", self.status as String), sortDescriptors: [NSSortDescriptor(key: "createTime", ascending: true)]) { (events: FetchedResults<Event>) in
                // use you wanted result
                // ...
                HStack {
                    Text(String(events.count))
                    ForEach(events, id: \.self) { event in
                        Text(event.name ?? "")
                    }
                }
            }

            // or this
            DynamicFetchView(fetchRequest: createRequest(status: self.status)) { (events: FetchedResults<Event>) in
                // use you wanted result
                // ...
                HStack {
                    Text(String(events.count))
                    ForEach(events, id: \.self) { event in
                        Text(event.name ?? "")
                    }
                }
            }
        }
    }

    func createRequest(status: String) -> NSFetchRequest<Event> {
        let request = Event.fetchRequest() as! NSFetchRequest<Event>
        request.predicate = NSPredicate(format: "status==%@", status as String)
        // warning: FetchRequest must have a sort descriptor
        request.sortDescriptors = [NSSortDescriptor(key: "createTime", ascending: true)]
        return request
    }
}

In this way, you can dynamic change your NSPredicate or NSSortDescriptor.

Electromotive answered 17/3, 2020 at 13:6 Comment(1)
If any other state changes causing the body to be recomputed a new fetch will be performed too!Robin
V
10

Modified @FKDev answer to work, as it throws an error, I like this answer because of its cleanliness and consistency with the rest of SwiftUI. Just need to remove the parentheses from the fetch request. Although @Antoine Weber answer works just the same.

But I am experience an issue with both answers, include mine below. This causes a weird side effect where some rows not related to the fetch request animate off screen to the right then back on screen from the left only the first time the fetch request data changes. This does not happen when the fetch request is implemented the default SwiftUI way.

UPDATE: Fixed the issue of random rows animating off screen by simply removing the fetch request animation argument. Although if you need that argument, i'm not sure of a solution. Its very odd as you would expect that animation argument to only affect data related to that fetch request.

@Binding var teamName: String

@FetchRequest var messages: FetchedResults<Message>

init() {

    var predicate: NSPredicate?
    // Can do some control flow to change the predicate here
    predicate = NSPredicate(format: "team.name == %@", teamName)

    self._messages = FetchRequest(
    entity: Message.entity(),
    sortDescriptors: [],
    predicate: predicate,
//    animation: .default)
}
Venezuela answered 6/11, 2019 at 20:45 Comment(9)
Does it work? I cannot create NSPredicate by using self in initSuh
Yes. I'm not sure I understand what you mean. I did not use self to create a NSPredicate in init. self is only used to assign it to the @FetchRequest var, but you need to make sure you use the underscore after self, like this self._varNameVenezuela
This is the best answer I can find anywhere online. Thanks a lot!Eldwin
Expression type 'NSPredicate' is ambiguous without more contextRabbi
This solution is actually the best, as it keeps a FetchRequest. Doing a manual fetch in the init only will not update the view on CoreData changes. This does! Perfect answerHagai
This option throw two errors 1: Variable 'self.teamName' used before being initialized 2: Return from initializer without initializing all stored propertiesNoncompliance
Your solution, which I like very much, gives me the error Variable 'self.messages' used before being initialized in the line predicate = NSPredicate (format:" team.name ==% @ ", teamName). Could you complete the example so that I can test it, why don't I understand how I can get rid of the error?Negligence
@OMiiNiiZE For me, I added inited the Binding in init like init(by: Binding<String>Tanner
For me, I added the Binding in init like init(teamName: Binding<String>) { self._ teamName = teamName }. However, when doing so, the rest of the code will report "self._messages" used before initialized. This solution seems like a paradox for me without a way to populate data into the @Binding, may I ask if I use it wrong? Thank you!Tanner
P
8

Another possibility is:

struct ChatView: View {

@Binding var teamName: String

@FetchRequest() var messages: FetchedResults<Message>

init() {
    let fetchRequest: NSFetchRequest<Message> = Message.fetchRequest()
    fetchRequest.sortDescriptors = NSSortDescriptor(keyPath: \Message.createdAt, ascending: true)
    fetchRequest = NSPredicate(format: "team.name == %@", teamName),
    self._messages = FetchRequest(fetchRequest:fetchRequest, animation: .default)
}

...
Pineal answered 2/10, 2019 at 8:16 Comment(2)
For me this resulted in the following compiler error: Cannot invoke initializer for type 'FetchRequest<_>' with no argumentsKeelykeen
you need to remove the (), but anyway I'm getting the error: Expression type 'NSPredicate' is ambiguous without more contextRabbi
R
5

I just had a similar problem, and am finding FetchRequest.Configuration helpful. Given this from the original code

@Binding var teamName: String

@FetchRequest(
    sortDescriptors: [NSSortDescriptor(keyPath: \Message.createdAt, ascending: true)],
    predicate: NSPredicate(format: "team.name == %@", "Developers"),
    animation: .default) var messages: FetchedResults<Message>

and, e.g., a TextField that binds to teamName, you can add an onChange handler that changes the predicate for messages:

TextField("Team Name", text: $teamName)
    .onChange(of: teamName) { newValue in
        messages.nsPredicate = newValue.isEmpty
        ? nil
        : NSPredicate(format: "team.name == %@", newValue)
    }

For more info see the docs for SwiftUI's FetchRequest.Configuration and in particular the section labelled Set configuration directly.

Rooke answered 24/9, 2022 at 23:25 Comment(2)
This is the only answer here that worked for me (Xcode 14.3, Swift 5.8)Alveolate
This has a bug when if the view gets updated then the predicate will reset to the initial one. Now there is a mismatch between the displayed teamName and the resultsAntilles
E
0

I had the same issue and my preferred solution is an extension of your parent NSManagedObject which returns a FetchRequest for the child objects. In a simple example I have a parent entity Classroom and Student child entities which have One-to-One relationship inClassroom with a Classroom entity.

So I create an extension to Classroom that generates the FetchRequest for me:

extension Classroom {
    
    /// Returns a FetchRequest that fetches students in this classroom
    var fetchedStudents: FetchRequest<Student> {
        let predicate : NSPredicate = NSPredicate(format: "inClassroom = %@", self)
        return FetchRequest<Student>(entity: Student.entity(), sortDescriptors: [NSSortDescriptor(key: "name", ascending: true)], predicate: predicate, animation: .default)
    }

}

And for your view which shows the child entities:

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

    private var students: FetchRequest<Student>
        
    init(classroom: Classroom) {
        self.students = classroom.fetchedStudents
    }
    
    var body: some View {
        List(students.wrappedValue, rowContent: { student in
            Text(student.name ?? "Unnamed")
        })
    }
}

I'm sure there are better solutions out there but this one is clean and works for me.

Elbaelbart answered 21/4 at 16:16 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.