Search code examples
iosswiftswiftuiobservedobjectviewbuilder

SwiftUI custom View's ViewBuilder doesn't re-render/update on subclassed ObservedObject update


This one I've been researching for a few days, scouring the Swift & SwiftUI docs, SO, forums, etc. and can't seem to find an answer.

Here is the problem;

I have a SwiftUI custom View that does some state determination on a custom API request class to a remote resource. The View handles showing loading states and failure states, along with its body contents being passed through via ViewBuilder so that if the state from the API is successful and the resource data is loaded, it will show the contents of the page.

The issue is, the ViewBuilder contents does not re-render when the subclassed ObservedObject updates. The Object updates in reaction to the UI (when buttons are pressed, etc.) but the UI never re-renders/updates to reflect the change within the subclassed ObservedObject, for example the ForEach behind an array within the subclassed ObservedObject does not refresh when the array contents change. If I move it out of the custom View, the ForEach works as intended.

I can confirm the code compiles and runs. Observers and debugPrint()'s throughout show that the ApiObject is updating state correctly and the View reflects the ApiState change absolutely fine. It's just the Content of the ViewBuilder. In which I assume is because the ViewBuilder will only ever be called once.

EDIT: The above paragraph should have been the hint, the ApiState updates correctly, but after putting extensive logging into the application, the UI was not listening to the publishing of the subclassed ObservedObject. The properties were changing and the state was too, but the UI wasn't being reactive to it. Also, the next sentence turned out to be false, I tested again in a VStack and the component still didn't re-render, meaning I was looking in the wrong place!

If this is the case, how does VStack and other such elements get around this? Or is it because my ApiObjectView is being re-rendered on the state change, in which causes the child view to 'reset'? Although in this circumstance I'd expect it to then take on the new data and work as expected anyway, its just never re-rendering.

The problematic code is in the CustomDataList.swift and ApiObjectView.swift below. I've left comments to point in the right direction.

Here is the example code;

// ApiState.swift
// Stores the API state for where the request and data parse is currently at.
// This drives the ApiObjectView state UI.

import Foundation

enum ApiState: String
{
    case isIdle

    case isFetchingData
    case hasFailedToFetchData

    case isLoadingData
    case hasFailedToLoadData

    case hasUsableData
}
// ApiObject.swift
// A base class that the Controllers for the app extend from.
// These classes can make data requests to the remote resource API over the
// network to feed their internal data stores.

class ApiObject: ObservableObject
{
    @Published var apiState: ApiState = .isIdle

    let networkRequest: NetworkRequest = NetworkRequest(baseUrl: "https://api.example.com/api")

    public func apiGetJson<T: Codable>(to: String, decodeAs: T.Type, onDecode: @escaping (_ unwrappedJson: T) -> Void) -> Void
    {
        self.apiState = .isFetchingData

        self.networkRequest.send(
            to: to,
            onComplete: {
                self.apiState = .isLoadingData

                let json = self.networkRequest.decodeJsonFromResponse(decodeAs: decodeAs)

                guard let unwrappedJson = json else {
                    self.apiState = .hasFailedToLoadData
                    return
                }

                onDecode(unwrappedJson)

                self.apiState = .hasUsableData
            },
            onFail: {
                self.apiState = .hasFailedToFetchData
            }
        )
    }
}
// DataController.swift
// This is a genericised example of the production code.
// These controllers build, manage and serve their resource data.
// Subclassed from the ApiObject, inheriting ObservableObject

import Foundation
import Combine

class CustomDataController: ApiObject
{
    @Published public var customData: [CustomDataStruct] = []

    public func fetch() -> Void
    {
        self.apiGetJson(
            to: "custom-data-endpoint ",
            decodeAs: [CustomDataStruct].self,
            onDecode: { unwrappedJson in
                self.customData = unwrappedJson
            }
        )
    }
}

This is the View that has the problem with re-rendering its ForEach on the ObservedObject change to its bound array property.

// CustomDataList.swift
// This is the SwiftUI View that drives the content to the user as a list
// that displays the CustomDataController.customData.
// The ForEach in this View 

import SwiftUI

struct CustomDataList: View
{
    @ObservedObject var customDataController: CustomDataController = CustomDataController()

    var body: some View
    {
        ApiObjectView(
            apiObject: self.customDataController,
            onQuit: {}
        ) {
            List
            {
                Section(header: Text("Custom Data").padding(.top, 40))
                {
                    ForEach(self.customDataController.customData, id: \.self, content: { customData in
                        // This is the example that doesn't re-render when the
                        // customDataController updates its data. I have
                        // verified via printing at watching properties
                        // that the object is updating and pushing the
                        // change.

                        // The ObservableObject updates the array, but this ForEach
                        // is not run again when the data is changed.

                        // In the production code, there are buttons in here that
                        // change the array data held within customDataController.customData.

                        // When tapped, they update the array and the ForEach, when placed
                        // in the body directly does reflect the change when
                        // customDataController.customData updates.
                        // However, when inside the ApiObjectView, as by this example,
                        // it does not.

                        Text(customData.textProperty)
                    })
                }
            }
            .listStyle(GroupedListStyle())
        }
        .navigationBarTitle(Text("Learn"))
        .onAppear() {
            self.customDataController.fetch()
        }
    }
}

struct CustomDataList_Previews: PreviewProvider
{
    static var previews: some View
    {
        CustomDataList()
    }
}

This is the custom View in question that doesn't re-render its Content.

// ApiObjectView
// This is the containing View that is designed to assist in the UI rendering of ApiObjects
// by handling the state automatically and only showing the ViewBuilder contents when
// the state is such that the data is loaded and ready, in a non errornous, ready state.
// The ViewBuilder contents loads fine when the view is rendered or the state changes,
// but the Content is never re-rendered if it changes.
// The state renders fine and is reactive to the object, the apiObjectContent
// however, is not.

import SwiftUI

struct ApiObjectView<Content: View>: View {
    @ObservedObject var apiObject: ApiObject

    let onQuit: () -> Void

    let apiObjectContent: () -> Content

    @inlinable public init(apiObject: ApiObject, onQuit: @escaping () -> Void, @ViewBuilder content: @escaping () -> Content) {
        self.apiObject = apiObject
        self.onQuit = onQuit
        self.apiObjectContent = content
    }

    func determineViewBody() -> AnyView
    {
        switch (self.apiObject.apiState) {
            case .isIdle:
                return AnyView(
                    ActivityIndicator(
                        isAnimating: .constant(true),
                        style: .large
                    )
                )

            case .isFetchingData:
                return AnyView(
                    ActivityIndicator(
                        isAnimating: .constant(true),
                        style: .large
                    )
                )

            case .isLoadingData:
                return AnyView(
                    ActivityIndicator(
                        isAnimating: .constant(true),
                        style: .large
                    )
                )

            case .hasFailedToFetchData:
                return AnyView(
                    VStack
                    {
                        Text("Failed to load data!")
                            .padding(.bottom)

                        QuitButton(action: self.onQuit)
                    }
                )

            case .hasFailedToLoadData:
                return AnyView(
                    VStack
                    {
                        Text("Failed to load data!")
                            .padding(.bottom)

                        QuitButton(action: self.onQuit)
                    }
                )

            case .hasUsableData:
                return AnyView(
                    VStack
                    {
                        self.apiObjectContent()
                    }
                )
        }
    }

    var body: some View
    {
        self.determineViewBody()
    }
}

struct ApiObjectView_Previews: PreviewProvider {
    static var previews: some View {
        ApiObjectView(
            apiObject: ApiObject(),
            onQuit: {
                print("I quit.")
            }
        ) {
            EmptyView()
        }
    }
}

Now, all the above code works absolutely fine, if the ApiObjectView isn't used and the contents placed in the View directly.

But, that is horrendous for code reuse and architecture, this way its nice and neat, but doesn't work.

Is there any other way to approach this, e.g. via a ViewModifier or a View extension?

Any help on this would be really appreciated.

As I said, I can't seem to find anyone with this problem or any resource online that can point me in the right direction to solve this problem, or what might be causing it, such as outlined in documentation for ViewBuilder.

EDIT: To throw something interesting in, I've since added a countdown timer to CustomDataList, which updates a label every 1 second. IF the text is updated by that timer object, the view is re-rendered, but ONLY when the text on the label displaying the countdown time is updated.


Solution

  • Figured it out after pulling my hair out for a week, its an undocumented issue with subclassing an ObservableObject, as seen in this SO answer.

    This is particularily annoying as Xcode obviously prompts you to remove the class as the parent class provides that inheritence to ObservableObject, so in my mind all was well.

    The fix is, within the subclassed class to manually fire the generic state change self.objectWillChange.send() via the willSet listener on the @Published variable in question, or any you require.

    In the examples I provided, the base class ApiObject in the question remains the same.

    Although, the CustomDataController needs to be modified as follows:

    // DataController.swift
    // This is a genericised example of the production code.
    // These controllers build, manage and serve their resource data.
    
    import Foundation
    import Combine
    
    class CustomDataController: ApiObject
    {
        @Published public var customData: [CustomDataStruct] = [] {
            willSet {
                // This is the generic state change fire that needs to be added.
                self.objectWillChange.send()
            }
        }
    
        public func fetch() -> Void
        {
            self.apiGetJson(
                to: "custom-data-endpoint ",
                decodeAs: [CustomDataStruct].self,
                onDecode: { unwrappedJson in
                    self.customData = unwrappedJson
                }
            )
        }
    }
    

    As soon as I added that manual publishing, the issue is resolved.

    An important note from the linked answer: Do not redeclare objectWillChange on the subclass, as that will again cause the state not to update properly. E.g. declaring the default

    let objectWillChange = PassthroughSubject<Void, Never>()
    

    on the subclass will break the state updating again, this needs to remain on the parent class that extends from ObservableObject directly, either my manual or automatic default definition (typed out, or not and left as inherited declaration).

    Although you can still define as many custom PassthroughSubject declarations as you require without issue on the subclass, e.g.

    // DataController.swift
    // This is a genericised example of the production code.
    // These controllers build, manage and serve their resource data.
    
    import Foundation
    import Combine
    
    class CustomDataController: ApiObject
    {
        var customDataWillUpdate = PassthroughSubject<[CustomDataStruct], Never>()
    
        @Published public var customData: [CustomDataStruct] = [] {
            willSet {
                // Custom state change handler.
                self.customDataWillUpdate.send(newValue)
    
                // This is the generic state change fire that needs to be added.
                self.objectWillChange.send()
            }
        }
    
        public func fetch() -> Void
        {
            self.apiGetJson(
                to: "custom-data-endpoint ",
                decodeAs: [CustomDataStruct].self,
                onDecode: { unwrappedJson in
                    self.customData = unwrappedJson
                }
            )
        }
    }
    

    As long as

    • The self.objectWillChange.send() remains on the @Published properties you need on the subclass
    • The default PassthroughSubject declaration is not re-declared on the subclass

    It will work and propagate the state change correctly.