Swiftui Coordinator Not Updating the Containing View's Property

SwiftUI coordinator not updating the containing view's property

Here is your code, a bit modified for demo, with used view model instance of ObservableObject holding your loading state.

import SwiftUI
import WebKit
import Combine

class WebViewModel: ObservableObject {
@Published var link: String
@Published var didFinishLoading: Bool = false

init (link: String) {
self.link = link
}
}

struct WebView: UIViewRepresentable {
@ObservedObject var viewModel: WebViewModel

let webView = WKWebView()

func makeUIView(context: UIViewRepresentableContext<WebView>) -> WKWebView {
self.webView.navigationDelegate = context.coordinator
if let url = URL(string: viewModel.link) {
self.webView.load(URLRequest(url: url))
}
return self.webView
}

func updateUIView(_ uiView: WKWebView, context: UIViewRepresentableContext<WebView>) {
return
}

class Coordinator: NSObject, WKNavigationDelegate {
private var viewModel: WebViewModel

init(_ viewModel: WebViewModel) {
self.viewModel = viewModel
}

func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
print("WebView: navigation finished")
self.viewModel.didFinishLoading = true
}
}

func makeCoordinator() -> WebView.Coordinator {
Coordinator(viewModel)
}

}

struct WebViewContentView: View {
@ObservedObject var model = WebViewModel(link: "https://apple.com")

var body: some View {
VStack {
TextField("", text: $model.link)
WebView(viewModel: model)
if model.didFinishLoading {
Text("Finished loading")
.foregroundColor(Color.red)
}
}
}
}

struct WebView_Previews: PreviewProvider {
static var previews: some View {
WebViewContentView()
}
}

SwiftUI View Not Updating to Reflect Data Changes in UIViewController

You could create a @Binding. This means that when the value is updated for data, the views are recreated to reflect the changes.

Here is how it can be done:

struct ContentView: View {

@State private var data = 3

var body: some View {
VStack {
MyViewControllerRepresentable(data: $data)
Text("super special property: \(data)")
}
}
}


class MyViewController: UIViewController {

@Binding private var data: Int

init(data: Binding<Int>) {
self._data = data
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}


override func viewDidLoad() {
let button = UIButton(type: .system)
button.setTitle("Increase by 1", for: .normal)
button.addTarget(self, action: #selector(buttonPressed), for: .touchUpInside)
view = button
}

@objc func buttonPressed() {
data += 1
}
}


struct MyViewControllerRepresentable: UIViewControllerRepresentable {

@Binding var data: Int
private let viewController: MyViewController

init(data: Binding<Int>) {
self._data = data
viewController = MyViewController(data: data)
}


func makeUIViewController(context: Context) -> UIViewController {
viewController
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}

View not reacting to changes of Published property when its chained from another ObservedObject

In case of scenario where there is a chain from View -> ViewModel -> Services:

@ObservedObject does not work on classes. Only works in case of SwiftUI View(structs). So if you want to observe changes on your view model from a service you need to manually listen/subscribe to it.

self.element.$value.sink(
receiveValue: { [weak self] _ in
self?.objectWillChange.send()
}
)

You can also use the .assign. Find more details here

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

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.

SwiftUI Coordinator: method can not be marked @objc

You don't need view there, so just use Any if you need action signature, like

@objc func dismissPreviewedScanTapped(sender: Any) {

}

Prevent reflow/redraw every time @State is changed


Quick fix

You can use an ObservableObject for this purpose

class Model: ObservableObject {
@Published var index: Int
init(index: Int) { self.index = index }
}

Create a container view for your text field that will update when this model changes:

struct IndexPreviewer: View {
@ObservedObject var model: Model
var body: Text { Text("Current Selected Index \(model.index)") }
}

Then include this model and the observer in your ContentView:

struct ContentView: View {
private let model = Model(index: -1)
var body: some View {
VStack {
IndexPreviewer(model: model)
CustomCollectionView(lastSelectedIndex: index)
}
}
var index: Binding<Int> {
Binding {model.index} set: {model.index = $0}
}
}

Explanation

The problem is that once you update a @State property, the containing view's body will be re-evaluated. So you cannot create a @State property on the view that contains your collection view, because each time you select a different cell, a message will be sent to your container who will re-evaluate it's body that contains the collection view. Hence the collection view will refresh and reload your data like Asperi wrote in his answer.

So what can you do to resolve that? Remove the state property wrapper from your container view. Because when you update the lastSelectedIndex, your container view (ContentView) should not be rendered again. But your Text view should be updated. So you should wrap your Text view in a separate view that observes the selection index.
This is where ObservableObject comes in to play. It is a class that can store State data on itself instead of being stored directly in a property of a view.

So why does IndexPreviewer update when the model changes and ContentView not, you might ask? That is because of the @ObservedObject property wrapper. Adding this to a view will refresh the view when the associated ObservableObject changes. That is why we do not include @ObservedObject inside ContentView but we do include it in IndexPreviewer.

How/Where to store your models?

For the sake of simplicity I added the model as a constant property to ContentView. This is however not a good idea when ContentView is not the root view of your SwiftUI hierarchy.
Say for example that your content view also receives a Bool value from its parent:

struct Wrapper: View {
@State var toggle = false
var body: some View {
VStack {
Toggle("toggle", isOn: $toggle)
ContentView(toggle: toggle)
}
}
}
struct ContentView: View {
let toggle: Bool
private let model = Model(index: -1)
...
}

When you run that on iOS 13 or 14 and try to click on collection view cell and then change the toggle, you will see that the selected index will reset to -1 when you change the toggle. Why does this happen?

When you click on the toggle, the @State var toggle will change and since it uses the @State property wrapper the body of the view will be recomputed. So another ContentView will be constructed and with it, also a new Model object.

There are two ways to prevent this from happening. One way is to move your model up in the hierarchy. But this can create a cluttered root view at the top of your view hierarchy. In some cases it is better to leave transient UI state local to your containing UI component. This can be achieved by an undocumented trick which is to use @State for your model objects. Like I said, it is currently (july 2020) undocumented but properties wrapped using @State will persist their value accross UI updates.

So to make a long story short: You should probably be storing your model using:

struct ContentView: View {
@State private var model = Model(index: -1)


Related Topics



Leave a reply



Submit