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
How to Go from Cmutablepointer<Cgfloat> to Cgfloat[] in Swift
Fill Circle with Wave Animation in Swiftui
How to Change the Current Day's Hours and Minutes in Swift
How to Disable User Interaction on Swiftui View
Differencebetween Final Class and Class
Swift: How to Implement a Login Storyboard
Calculate the Angle Between a Line and X-Axis
Wkwebview Auto Fill Login Form Swift 2
Gradient as Foreground Color of Text in Swiftui
Scenedidload Being Called Twice
Swift: Floating Plus Button Over Tableview Using the Storyboard
How to Program a Nsoutlineview
Building a Spritekit/Gamekit Leaderboard Within a Specific Scene
Controlling Size of Texteditor in Swiftui