I am currently writing a ViewModel that uses the @Published property wrapper to update data pulled from by backend.
I have a class ViewResult that allows the frontend to display the state of the pulled data, so the published variable is of type ViewResult. I am worried my understanding of ObservableObject and @Published is wrong. Here is it's definition:
class ViewResult<T> : ObservableObject {
@Published var data: T?
var state: ViewResultState = .loading
var errorCode: APIResponseCode? = nil
init(){
self.state = .loading
self.data = nil
}
///OTHER COVIENIENCE INITIALIZERS NOT SHOWN
///Sets object and state variables if the APIResponse was successful.
///If it was unsuccessful, it sets error code and sets the ViewResult State to code.
func SetData(_ result: APIResult<T>) -> Void {
switch result {
case .success(let data):
self.data = data.data
self.state = .object
case .failure(let error):
self.errorCode = APIResponseCode.Error(error)
self.state = .code
}
}
The ViewModel itself is pretty basic, here is a specific example in which I am working:
class GuidelinesVM : ObservableObject {
var community_id: Int
@Published var guidelines: ViewResult<[Guideline]> = ViewResult(defaultValue: [])
private var dispatcher : ViewModelAPIDispatcher = ViewModelAPIDispatcher()
init(community_id: Int){
self.community_id = community_id
//Get guidelines before view is initialized.
//**NOTE: I AM CHANGING THIS TO EXECUTE IN THE ONAPPEAR CLOSURE
dispatcher.SendRequest(.getCommunityGuidlines(community_id: community_id)){ result in
self.guidelines.SetData(result)
}
}
func ReorderGuidelines(_ fromOffsets : IndexSet, _ toOffset : Int){
//Conversions from Swift's List system to mine
let oldOffset = fromOffsets.first! + 1
let guideline_id = self.guidelines.data![oldOffset - 1].id
let newOffset = toOffset > oldOffset ? toOffset : toOffset + 1
//Dispatch API Call
dispatcher.SendRequest(.reOrderGuidelines(community_id: community_id, guideline_id: guideline_id, fromOffset: oldOffset, toOffset: toOffset)) { apiResult in
if IsSuccessful(apiResult) {
self.guidelines.data!.move(fromOffsets: fromOffsets, toOffset: toOffset)
}
}
}
func DeleteGuideline(_ offsets: IndexSet){
let index = offsets.first!
dispatcher.SendRequest(.deleteGuideline(community_id: community_id, guideline_id: index)){ result in
if IsSuccessful(result) {
self.guidelines.data!.remove(at: index)
}
}
}
func CreateGuidline(orderNumber: Int, text: String) {
dispatcher.SendRequest(.createGuideline(community_id: community_id, orderNumber: orderNumber, text: text)){
result in
if(IsSuccessful(result)){
//Right now just generate random int. I know this is terrible and lazy, but MVP
self.guidelines.data!.append(Guideline(id: Int.random(in: -1000...0), order_number: self.guidelines.data!.count + 1, text: text))
}
}
}
}
Now with that in mind, here is the view to which the @Published variable is being used:
struct CommunityGuidelinesView: View {
var psCommunityData : psCommunityClass
@State private var newGuideline : String = ""
@ObservedObject private var VM : GuidelinesVM
init(community : psCommunityClass){
self.psCommunityData = community
self.VM = GuidelinesVM(community_id: community.id)
}
var body: some View {
EditableView(userRole: psCommunityData.role){ inEditMode in
VStack{
APIResultView(apiResult: $VM.guidelines){ guidelines in
List {
// LIST DISPLAY CODE
}
.onDelete { indexSet in
VM.DeleteGuideline(indexSet)
}
.onMove{ fromOffsets, newOffset in
VM.ReorderGuidelines(fromOffsets, newOffset)
}
//NEED TO PASS A BINDING FOR NESTED VIEW CONTROL
if inEditMode.wrappedValue {
EditingView(
//VIEW IF NOT EDITING
},
EditingContent: {
//VIEW IF EDITING
}, completion: {
//FUNCTION EXECTUED ONCE DONE EDITING
VM.CreateGuidline(orderNumber: guidelines.count + 1, text: newGuideline)
}
)
}
}
}
}
}
}
Now with that knowledge, I believe the issue comes in with the editing view. I have the data displaying correctly when the view appears, and everyhere else in the application. I can successfully edit the object inside the class, I.E. adding to the list, deleting from it, and reording it. But the ListView that is displayedc, VM.Guidelines, doesn't update until "Edit Mode" is toggled back to "View Mode". The behavior goes something like this:
1.) Enter edit mode by hitting edit on the top right toolbar.
2.) Enter some text and execute VM.CreateGuideline().
3.) The API call is successful (I know this) and edits the data with the @Published guidelines variable.
4.) View doesn't update.
5.) Hit "Done" on the top right.
6.) List View is updated with new list item.
I'm worried that I am using these Combine defined aspects of Swift incorrectly. It also seems plausible, however that the nested nature of EditableView and APIResultView are getting in the way of one another in some capacity.
Also: One last piece of code that might be necessary is the APIResultView that handles the display logic for incoming data from the backend:
struct APIResultView<SuccessDisplay : View, T>: View {
@Binding var apiResult : ViewResult<T>
var content : (T) -> SuccessDisplay
var body: some View {
switch apiResult.state{
case .object:
content(apiResult.data!)
case .loading:
ProgressView("Loading...")
case .code:
if let errorCode = apiResult.errorCode {
HttpStatusView(statusCode: errorCode)
} else {
HttpStatusView(statusCode: apiResult.data as! APIResponseCode)
}
}
}
All things I've tried are just in hopes of getting the List to update.
I've tried adding @escaping closures to the VM function calls (CreateGuideline, DeleteGuideline, ReorderGuidelines). In hopes of editing the data from the View directly. I hate this because it defeats the point of my software architecture and the functions of objects in it. Luckily, it didn't work anyways.
I've tried passing the arguments through inout parameters in hopes that by making it a reference it would update one place in memory, though I think that happens anyways.
That's about it. Just been trial and erroring otherwise. No other thought out solutions.
While I didn't study your code or text in extreme detail, I noticed something that is probably causing your problem:
ViewResult
is a class
, conforms ObservableObject
, and is also used as the type of a Published
property in GuidelinesVM
.
This is a recipe for trouble.
The problem is that changes to the properties of a ViewResult
, including changes to the data
property and changes to properties of the data
value, will not show up as changes to the properties of the GuidelinesVM
.
For example:
let gvm = GuidelinesVM(community_id: 1)
gvm.guidelines = ViewResult()
// The assignment above does cause gvm to fire its objectWillChange publisher.
gvm.guidelines.state = .object
// The assignment above DOES NOT cause gvm to fire its objectWillChange publisher.
// It also does not cause gvm.guidelines to fire its objectWillChange publisher,
// because the state property is not @Published.
gvm.guidelines.data = []
// The assignment above DOES NOT cause gvm to fire its objectWillChange publisher.
// It causes gvm.guidelines to fire its objectWillChange publisher.
gvm.guidelines.data[0].text = "blerg"
// The assignment above DOES NOT cause gvm to fire its objectWillChange publisher.
// It causes gvm.guidelines to fire its objectWillChange publisher.
I recommend changing ViewResult
to a struct
(and removing ObservableObject
and @Published
from it). Making it a struct
will make GuidelinesVM
see any changes to its properties and fire objectWillChange
.
If you can't make it a struct
for some reason, then you need to manually propagate changes in ViewResult
to changes in GuidelinesVM
, perhaps by making GuidelinesVM
subscribe to guidelines.objectWillChange
:
class GuidelinesVM : ObservableObject {
var community_id: Int
@Published var guidelines: ViewResult<[Guideline]> = ViewResult(defaultValue: [])
private var dispatcher : ViewModelAPIDispatcher = ViewModelAPIDispatcher()
private var tickets: [AnyCancellable] = []
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ADD THIS
init(community_id: Int){
self.community_id = community_id
guidelines.objectWillChange.sink { [weak self] in
self?.objectWillChange.send()
}.store(in: &tickets)
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ADD THIS
//Get guidelines before view is initialized.
//**NOTE: I AM CHANGING THIS TO EXECUTE IN THE ONAPPEAR CLOSURE
dispatcher.SendRequest(.getCommunityGuidlines(community_id: community_id)){ result in
self.guidelines.SetData(result)
}
}
If you use this approach, you may also need to add `@Published` to the `state` and `errorCode` properties of `ViewResult`. You will also need to do extra work to update the subscription if you ever assign an entirely new `ViewResult` instance to the `guidelines` property.