【问题标题】:SwiftUI custom View's ViewBuilder doesn't re-render/update on subclassed ObservedObject updateSwiftUI 自定义视图的 ViewBuilder 不会在子类 ObservedObject 更新时重新渲染/更新
【发布时间】:2020-05-25 15:53:02
【问题描述】:

我已经研究了几天,搜索了 Swift 和 SwiftUI 文档、SO、论坛等,但似乎找不到答案。

这就是问题所在;

我有一个 SwiftUI 自定义视图,它对远程资源的自定义 API 请求类进行一些状态确定。 View 处理显示加载状态和失败状态,以及通过 ViewBuilder 传递其主体内容,因此如果来自 API 的状态成功并且资源数据已加载,它将显示页面内容。

问题是,当子类 ObservedObject 更新时,ViewBuilder 内容不会重新呈现。对象会根据 UI 进行更新(按下按钮时等),但 UI 从不重新渲染/更新以反映子类 ObservedObject 中的更改,例如,子类 ObservedObject 中数组后面的 ForEach 不会刷新数组内容改变。如果我将其移出自定义视图,则 ForEach 会按预期工作。

我可以确认代码编译并运行。观察者和debugPrint() 的自始至终表明ApiObject 正在正确更新状态,并且视图反映了ApiState 的变化非常好。它只是 ViewBuilder 的Content。我假设是因为 ViewBuilder 只会被调用一次。

编辑:上面的段落应该是提示,ApiState 正确更新,但是在将大量日志记录到应用程序之后,UI 没有监听子类 ObservedObject 的发布。属性在变化,状态也在变化,但 UI 并没有对此做出反应。 还有,下一句是假的,我在VStack中再次测试,组件仍然没有重新渲染,说明我找错地方了!

如果是这样,VStack 和其他此类元素如何解决这个问题? 还是因为我的ApiObjectView 在状态更改时重新渲染,导致子视图“重置”?尽管在这种情况下,我希望它能够接收新数据并按预期工作,但它永远不会重新渲染。

有问题的代码在下面的CustomDataList.swiftApiObjectView.swift 中。我已经让 cmets 指向正确的方向。

这里是示例代码;

// 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
            }
        )
    }
}

这是在将 ObservedObject 更改为绑定数组属性时重新渲染其 ForEach 时遇到问题的视图。

// 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()
    }
}

这是有问题的自定义视图,不会重新呈现其内容。

// 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()
        }
    }
}

现在,如果不使用ApiObjectView 并将内容直接放在视图中,上述所有代码都可以正常工作。

但是,这对于代码重用和架构来说是可怕的,这样它既漂亮又整洁,但不起作用。

有没有其他方法可以解决这个问题,例如通过ViewModifierView 扩展?

对此的任何帮助将不胜感激。

正如我所说,我似乎找不到任何有此问题的人或任何在线资源可以为我指明解决此问题的正确方向,或者可能导致问题的原因,例如 ViewBuilder 文档中的概述。

编辑:为了加入一些有趣的东西,我已经为CustomDataList 添加了一个倒数计时器,它每 1 秒更新一个标签。 如果该计时器对象更新了文本,则视图会重新呈现,但当标签上显示倒计时时间的文本更新时。

【问题讨论】:

    标签: ios swift swiftui observedobject viewbuilder


    【解决方案1】:

    在拔掉我的头发一周后想通了,这是一个未记录的问题,将ObservableObject 子类化,如SO answer 所示。

    这特别烦人,因为 Xcode 显然会提示您删除该类,因为父类提供了对 ObservableObject 的继承,所以在我看来一切都很好。

    修复方法是,在子类中通过相关@Published 变量上的willSet 侦听器手动触发通用状态更改self.objectWillChange.send(),或者您需要的任何变量。

    在我提供的示例中,问题中的基类ApiObject 保持不变。

    虽然CustomDataController需要修改如下:

    // 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
                }
            )
        }
    }
    

    只要我添加了手动发布,问题就解决了。

    链接答案中的重要说明:不要在子类上重新声明 objectWillChange,因为这将再次导致状态无法正确更新。例如。声明默认值

    let objectWillChange = PassthroughSubject<Void, Never>()
    

    子类上会再次中断状态更新,这需要保留在直接从ObservableObject扩展的父类上,无论是我的手动还是自动默认定义(输入或不输入并保留为继承声明)。

    尽管您仍然可以根据需要定义任意数量的自定义 PassthroughSubject 声明,而不会在子类上出现问题,例如

    // 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
                }
            )
        }
    }
    

    只要

    • self.objectWillChange.send() 保留在您需要的子类的 @Published 属性中
    • 默认的PassthroughSubject声明不会在子类上重新声明

    它将正常工作并正确传播状态更改。

    【讨论】:

    • 谢谢,这确实解决了我的问题。令人沮丧的是 Xcode 没有警告你......
    • @JoakimSjo 从 Swift 5.4 开始,他们已经显着改进了错误报告,希望能解决所有这些问题。它不应再卡在嵌套视图和组件上。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2020-05-02
    • 2020-10-22
    • 1970-01-01
    • 1970-01-01
    • 2020-12-21
    相关资源
    最近更新 更多