skip to Main Content

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


  1. Given that GlobeViewModel is an environment object initialized at the base of your app, and you want GlobeViewController to have a direct reference to it, your current approach should work: You are already passing the GlobeViewModel to GlobeViewController via the initializer.

    The challenge is to execute a function in GlobeViewController from GlobeView without causing issues like multiple initializations of SCNParticleSystem.

    You could try and implement a mechanism in GlobeViewController to observe changes in GlobeViewModel, and react accordingly.

    +--------------------+         +--------------------------+
    |                    |         |                          |
    |   MainProfile      |         |    GlobeView             |
    |   (SwiftUI View)   |         |    (SwiftUI View)        |
    |   @ObservedObject  |         |   Uses EnvironmentObject |
    |    viewModel       |         |    viewModel             |
    |                    |         |                          |
    +---------+----------+         +--------------+-----------+
              |                                   |
              | Pass viewModel                    | Pass viewModel
              |                                   |
              v                                   v
    +---------+----------+         +-------------+------------+
    |                    |         |                          |
    |  GlobeViewModel    |---------|  GlobeViewController     |
    | (ObservableObject) |         |  (UIKit ViewController)  |
    |                    |         |                          |
    +--------------------+         +--------------------------+
    

    The GlobeViewController would be:

    public class GlobeViewController: GenericController {
        var viewModel: GlobeViewModel
        private var isParticleSystemInitialized = false
    
        // Existing initializer code
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            // Setup observer for executeFunction
            viewModel.$executeFunction.sink { [weak self] newValue in
                if newValue {
                    self?.executeRequiredFunction()
                }
            }.store(in: &cancellables) // Assuming you have a collection of AnyCancellable
        }
    
        private func executeRequiredFunction() {
            // Check to avoid reinitialization
            if !isParticleSystemInitialized {
                // Your logic to initialize SCNParticleSystem
                isParticleSystemInitialized = true
            }
        }
    }
    

    GlobeViewController observes changes in GlobeViewModel using Combine’s sink method. That setup allows GlobeViewController to react to changes in the view model.

    The isParticleSystemInitialized flag makes sure the initialization of SCNParticleSystem occurs only once, preventing the issue of multiple initializations.

    Since GlobeViewModel is an environment object, you can pass it to GlobeViewController without reinitializing it in MainProfile.

    See if that would maintain the connection between GlobeViewController and GlobeViewModel, allowing direct modification of variables, while also preventing the reinitialization issue you were experiencing.

    Login or Signup to reply.
  2. You need to remove the View model object, in SwiftUI that’s what let, body, @State and @Binding are for. In your UIViewControllerRepresentable it is essential to implement the updateUIViewController func which is called whenever a let, @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 as View, i.e. in a View, body is called whenever a let, @State or @Biniding is changed, and in UIViewControllerRepresentable, updateUIViewController is called whenever a let, @State or @Biniding is changed in exactly the same way. Use let for read access, use @Binding var for read/write. Place the State at highest common parent of all the View structs or UIViewControllerRepresentable structs where the data is needed. Something like this:

    private struct GlobeViewControllerRepresentable: UIViewControllerRepresentable {
        let isInitialized: Bool // let because view controller doesn't write to this
        
        // called once
        func makeUIViewController(context: Context) -> GlobeViewController {
            GlobeViewController(earthRadius: 1.0)
        }
        
        // called every time a `let` or `@Binding var` changes.
        func updateUIViewController(_ uiViewController: GlobeViewController, context: Context) { 
            if isInitialized && !uiViewController.isParticleSystemInitialized {
                uiViewController.executeRequiredFunction()
            }
        }
    }
    
    Login or Signup to reply.
  3. 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.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search