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")
}
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
calls updateUIViewController
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.
@StateObject var viewModel: GlobeViewModel = .init()
Second, the UIViewControllerRepresentable
should be as simple as possible (No SwiftUI property wrappers or additional arguments).
private struct GlobeViewControllerRepresentable: UIViewControllerRepresentable {
typealias UIViewControllerType = GlobeViewController
let viewModel: GlobeViewModel
func makeUIViewController(context: Context) -> GlobeViewController {
print(#function)
return UIViewControllerType(popRoot: viewModel)
}
func updateUIViewController(_ uiViewController: GlobeViewController, context: Context) {
print(#function)
}
}
These 2 things ensure that makeUIViewController
and updateUIViewController
only get called once because GlobeViewControllerRepresentable
identity is tied to the StateObject
.
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
in SwiftUI
you can create a reference to the UIViewController
in the ViewModel
.
class GlobeViewModel: ObservableObject {
weak var controller: GlobeViewController?
@Published var option: Int = 2
@Published var hideSearch: Bool = false
@Published var executeFunction: Bool = false
@Published var earthRadius: Int = 1
func increaseRadius() {
guard let controller else {return}
controller.increaseRadius()
}
}
And to observe the changes in the ViewModel you can use Combine
import Combine
public class GlobeViewController: UIViewController {
weak var viewModel: GlobeViewModel!
private var anyCancellable: Set<AnyCancellable> = .init()
init(popRoot: GlobeViewModel) {
self.viewModel = popRoot
super.init(nibName: nil, bundle: nil)
viewModel.controller = self
viewModel.$earthRadius.sink { [weak self] radius in
print("new earth radius \(radius)")
}.store(in: &anyCancellable)
}
deinit {
anyCancellable.removeAll()
viewModel.controller = nil
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override func viewDidLoad() {
super.viewDidLoad()
let button: UIButton = .init(frame: .zero)
button.setTitle("Increase Radius", for: .normal)
button.configuration = .borderedProminent()
button.addTarget(self, action: #selector(increaseRadius), for: .touchUpInside)
view.addSubview(button)
//pin to edges
button.translatesAutoresizingMaskIntoConstraints = false
button.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
button.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
button.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
button.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
}
@objc func increaseRadius() {
viewModel.earthRadius += 1
}
}
You can test this code with the code above and
struct MainProfile: View {
@StateObject var viewModel: GlobeViewModel = .init()
var body: some View {
VStack{
Text(viewModel.earthRadius, format: .number)
GlobeViewControllerRepresentable(viewModel: viewModel) //call function from here
Button("Increase Radius") {
viewModel.increaseRadius()
}
}.environmentObject(viewModel)
}
}
#Preview {
MainProfile()
}
You'll notice in the sample that increaseRadius
declared in the UIViewController
is called by the Button
via the ViewModel and the sink
print
when the value changes.