I have GlobeView()
embedded in a SwiftUI view. I want to call a function to execute from the SwiftUI view to the GlobeViewController
.
What’s the easiest way I can achieve this given this layered view hierarchy?
I know I can pass down a simple closure, but in doing so, I get a bug where SCNParticleSystem
is infinitely added to the SceneView
since the init fires every time the view updates.
Is there an alternative approach where I can call this function?
Is there a way to detect variable changes in viewModel
from GlobeViewController
?
I know I can use didset
on viewModel
, but how would I know when a specific member variable of viewModel
is set?
import SwiftUI
import SceneKit
import Foundation
import SceneKit
import CoreImage
import MapKit
class GlobeViewModel: ObservableObject {
@Published var option: Int = 2
@Published var hideSearch: Bool = false
@Published var executeFunction: Bool = false
}
struct MainProfile: View {
@EnvironmentObject var viewModel: GlobeViewModel
var body: some View {
GlobeView() //call function from here
}
}
typealias GenericControllerRepresentable = UIViewControllerRepresentable
@available(iOS 13.0, *)
private struct GlobeViewControllerRepresentable: GenericControllerRepresentable {
@EnvironmentObject var viewModel: GlobeViewModel
func makeUIViewController(context: Context) -> GlobeViewController {
let globeController = GlobeViewController(earthRadius: 1.0, popRoot: viewModel)
return globeController
}
func updateUIViewController(_ uiViewController: GlobeViewController, context: Context) { }
}
@available(iOS 13.0, *)
public struct GlobeView: View {
public var body: some View {
GlobeViewControllerRepresentable()
}
}
public typealias GenericController = UIViewController
public typealias GenericColor = UIColor
public typealias GenericImage = UIImage
public class GlobeViewController: GenericController {
var viewModel: GlobeViewModel
public var earthNode: SCNNode!
internal var sceneView : SCNView!
private var cameraNode: SCNNode!
init(popRoot: GlobeViewModel) {
self.viewModel = popRoot
super.init(nibName: nil, bundle: nil)
}
init(popRoot: GlobeViewModel) {
self.viewModel = popRoot
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
3
Answers
Given that
GlobeViewModel
is an environment object initialized at the base of your app, and you wantGlobeViewController
to have a direct reference to it, your current approach should work: You are already passing theGlobeViewModel
toGlobeViewController
via the initializer.The challenge is to execute a function in
GlobeViewController
fromGlobeView
without causing issues like multiple initializations ofSCNParticleSystem
.You could try and implement a mechanism in
GlobeViewController
to observe changes inGlobeViewModel
, and react accordingly.The
GlobeViewController
would be:GlobeViewController
observes changes inGlobeViewModel
using Combine’ssink
method. That setup allowsGlobeViewController
to react to changes in the view model.The
isParticleSystemInitialized
flag makes sure the initialization ofSCNParticleSystem
occurs only once, preventing the issue of multiple initializations.Since
GlobeViewModel
is an environment object, you can pass it toGlobeViewController
without reinitializing it inMainProfile
.See if that would maintain the connection between
GlobeViewController
andGlobeViewModel
, allowing direct modification of variables, while also preventing the reinitialization issue you were experiencing.You need to remove the View model object, in SwiftUI that’s what
let
,body
,@State
and@Binding
are for. In yourUIViewControllerRepresentable
it is essential to implement theupdateUIViewController
func which is called whenever alet
,@State
or@Binding
value is changed. That is where you use the struct’s values to update your view controller object (if any values have changed since last time).UIViewControllerRepresentable
works the same asView
, i.e. in aView
,body
is called whenever alet
,@State
or@Biniding
is changed, and inUIViewControllerRepresentable
,updateUIViewController
is called whenever alet
,@State
or@Biniding
is changed in exactly the same way. Uselet
for read access, use@Binding var
for read/write. Place the State at highest common parent of all theView
structs orUIViewControllerRepresentable
structs where the data is needed. Something like this:Part 1
The key to getting the the particle system (or anything else) to only initialize once is understanding how and when SwiftUI recreates the View.
Right now your
GlobeViewControllerRepresentable
callsupdateUIViewController
anytime anything in the@EnvironmentObject var viewModel: GlobeViewModel
changes.Because of this the
updateUIViewController
can easily become complex because you have to create a series of checks to make sure you aren’t duplicating work.But the alternative is to use SwiftUI’s storage and identity management in your favor…
First, your ViewModel (or whatever anyone else prefers to call it) should be initialized as a
@StateObject
. This is very important.Second, the
UIViewControllerRepresentable
should be as simple as possible (No SwiftUI property wrappers or additional arguments).These 2 things ensure that
makeUIViewController
andupdateUIViewController
only get called once becauseGlobeViewControllerRepresentable
identity is tied to theStateObject
.Once this is setup you can have a UIKit setup and a SwiftUI setup (best of both worlds).
Part 2
To call a function from the
UIViewController
inSwiftUI
you can create a reference to theUIViewController
in theViewModel
.And to observe the changes in the ViewModel you can use
Combine
You can test this code with the code above and
You’ll notice in the sample that
increaseRadius
declared in theUIViewController
is called by theButton
via the ViewModel and thesink
print
when the value changes.