SwiftUI пользовательский View ViewBuilder не перерисовывает / обновляет в подклассе обновления ObservedObject - PullRequest
1 голос
/ 10 февраля 2020

Я изучал это несколько дней, изучая документы Swift & SwiftUI, SO, форумы и т. Д. c. и не могу найти ответ.

Вот проблема;

У меня есть пользовательское представление SwiftUI, которое выполняет определенное состояние для пользовательского класса запроса API для удаленного ресурса. View обрабатывает состояния загрузки и состояния сбоя, а также содержимое его тела, передаваемое через ViewBuilder, так что если состояние из API является успешным и данные ресурса загружаются, он отображает содержимое страницы.

Проблема в том, что содержимое ViewBuilder не отображается повторно при обновлении подкласса ObservedObject. Объект обновляется в ответ на пользовательский интерфейс (при нажатии кнопок и т. Д. c.), Но пользовательский интерфейс никогда не выполняет повторную визуализацию / обновление, чтобы отразить изменение в подклассе ObservedObject, например, ForEach за массивом в подклассе ObservedObject делает не refre sh при изменении содержимого массива. Если я переместу его из пользовательского представления, ForEach будет работать так, как задумано.

Я могу подтвердить, что код компилируется и запускается. Наблюдатели и повсюду debugPrint() показывают, что ApiObject корректно обновляет состояние, а представление полностью отражает изменение ApiState. Это просто Content ViewBuilder. Я полагаю, это потому, что ViewBuilder будет вызываться только один раз.

EDIT : вышеупомянутый абзац должен был быть подсказкой, ApiState обновляется правильно, но после интенсивной регистрации в приложении пользовательский интерфейс не прослушивал публикацию подкласса ObservedObject. Свойства менялись, и состояние тоже было, но пользовательский интерфейс не реагировал на это. Кроме того, следующее предложение оказалось ложным, я снова проверил в VStack, и компонент все еще не рендерился, то есть я искал не в том месте!

Если это так, как VStack и другие подобные элементы обходят это? Или это потому, что мой ApiObjectView перерисовывается при изменении состояния, в результате чего дочернее представление «сбрасывается»? Несмотря на то, что в этих обстоятельствах я ожидал бы, что он примет новые данные и будет работать так, как ожидалось, в любом случае, он просто никогда не будет повторно отображаться.

Код проблемного c находится в CustomDataList.swift и ApiObjectView.swift ниже. Я оставил комментарии, чтобы они указывали в правильном направлении.

Вот пример кода;

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

Это представление, которое имеет проблему с рендеринг его ForEach при изменении ObservedObject в свойстве связанного массива.

// 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 не используется, а содержимое помещается непосредственно в представление.

Но это ужасно для повторного использования кода и архитектуры, поэтому его красиво и аккуратно, но не работает.

Есть ли какой-нибудь другой способ приблизиться к этому, например, с помощью расширения ViewModifier или View?

Любая помощь по этому вопросу будет очень признателен.

Как я уже сказал, я не могу найти никого с этой проблемой или любого ресурса в Интернете, который мог бы указать мне правильное направление для решения этой проблемы, или что может быть причиной, такой как, например, изложенные в документации для ViewBuilder.

РЕДАКТИРОВАТЬ: Чтобы бросить некоторые Что интересно, с тех пор я добавил таймер обратного отсчета к CustomDataList, который обновляет метку каждую 1 секунду. ЕСЛИ текст обновляется этим объектом таймера, представление перерисовывается, но ТОЛЬКО , когда текст на метке, отображающей время обратного отсчета, обновляется.

1 Ответ

3 голосов
/ 11 февраля 2020

Понял это после того, как выдернул мои волосы в течение недели, это недокументированная проблема с подклассом ObservableObject, как видно из этого SO ответа .

Это особенно раздражает как Xcode, очевидно, предлагает вам удалить класс, поскольку родительский класс обеспечивает это наследование ObservableObject, так что, на мой взгляд, все было хорошо.

Исправление в том, что в подклассовом классе вручную запускается обобщенный c изменение состояния self.objectWillChange.send() с помощью прослушивателя willSet для рассматриваемой переменной @Published или любой другой, которая вам требуется.

В приведенных мною примерах базовый класс 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, либо в моем ручном, либо в автоматическом c определении по умолчанию ( набрано или нет и оставлено как унаследованное объявление).

Хотя вы все равно можете определить столько пользовательских объявлений 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
            }
        )
    }
}

As Пока

  • self.objectWillChange.send() остается в нужных @Published свойствах подкласса
  • Объявление PassthroughSubject по умолчанию не повторно объявляется в подклассе

Он будет работать и правильно распространять изменения состояния.

...