SwiftUI View is not updating when #Published changed in ObservableObject - ios

I know there are a lot of questions out there with similar issues but almost all of them are reference/value issues. My case is a little different
I have a Viewcontroller that leverages UIHostingController and add's a SwiftUI view.
I have a viewmodel that's an ObservableObject and referenced in ViewController and then passed to SwiftUI View.
However if I update some values in ObservableObject it is not reflected to SwiftUI
What am I doing wrong here?
class DeveloperScreenViewController1: UIViewController {
let developerScreenViewModel: DeveloperScreenViewModel
private lazy var contentView: UIHostingController = {
UIHostingController(rootView: DeveloperScreenView(developerScreenViewModel: developerScreenViewModel))
}()
init(viewModel: DeveloperScreenViewModel) {
self.developerScreenViewModel = viewModel
super.init(nibName: nil, bundle: Bundle.main)
}
override func viewDidLoad() {
super.viewDidLoad()
setupConstraints()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.developerScreenViewModel.setup()
}
private func setupConstraints() {
// add swiftUI view as a subview
addChild(self.contentView)
self.contentView.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(self.contentView.view)
// set constraints to match the parent viewcontroller's view
NSLayoutConstraint.activate([
view.leadingAnchor.constraint(equalTo: contentView.view.leadingAnchor),
view.trailingAnchor.constraint(equalTo: contentView.view.trailingAnchor),
view.topAnchor.constraint(equalTo: contentView.view.topAnchor),
view.bottomAnchor.constraint(equalTo: contentView.view.bottomAnchor)
])
}
And then in ViewModel class
class DeveloperScreenViewModel: ObservableObject {
#Published var environments: [Endpoint] = []
#Published var realTimeUpdateToggle:Bool = false
public init() {
// self.setup()
}
public func setup() {
self.initializeListItems()
self.initializeRealTimeToggle()
}
private func initializeListItems() {
self.environments = EnvironmentStore.shared.changeableEndpoints
}
func initializeRealTimeToggle() {
realTimeUpdateToggle = RealTimeUpdatesDeveloperUtils.isEnabled()
}
and SwiftUI is something like
struct DeveloperScreenView: View {
#ObservedObject var developerScreenViewModel: DeveloperScreenViewModel
var body: some View {
VStack {
DeveloperScreenEnvironmentView(endPoints: developerScreenViewModel.environments)
DeveloperScreenRealTimeUpdateToggleView(realTimeToggleItem: developerScreenViewModel.realTimeUpdateToggle)
}
}
When changes are made to this vars, lets say add a new element to array or set realTimeUpdateToggle to true, view in SwiftUI just dont get updated
#Published var environments: [Endpoint] = []
#Published var realTimeUpdateToggle:Bool = false

Related

How do I access a #StateObject in a SwiftUI view and update it from a view controller?

I have the following StateObject in my SwiftUI view:
#StateObject var state = AnnotateImageToolbarState()
Where AnnotateImageToolbarState:
final class AnnotateImageToolbarState: ObservableObject {
enum Constants {
static let defaultColor: Color = .black
}
#Published var selectedColor: Color? = Constants.defaultColor
#Published var isEraserSelected = false
#Published var isAnnotateText = false
func resetDefaults() {
selectedColor = Constants.defaultColor
isEraserSelected = false
}
}
I init my SwiftUI View in a view controller:
// MARK: - Views
private let toolbar = AnnotateImageToolbar()
private lazy var hostingController = UIHostingController(rootView: toolbar)
// MARK: - View Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
setupViews()
}
// MARK: - Setup
private func setupViews() {
view.backgroundColor = Constants.backgroundColor
addChild(hostingController)
view.addSubview(hostingController.view)
updateToolbarConstraints(isHidden: false)
}
I want to be able to update values in the state from the view controller, eg in my view controller:
private func updateToolbarType() {
toolbar.state.isAnnotateText = true
}
However I get the error Accessing StateObject's object without being installed on a View. This will create a new instance each time.
How would I go about changing the state values from the view controller?

Need Help Maintaining Swift Environment Object

Trying to place a SwiftUI View into a ViewController using UIHostingController, but receiving the following error:
No ObservableObject of type UIStateModel found. A View.environmentObject(_:) for UIStateModel may be missing as an ancestor of this view.
All help appreciated, I've been quite stuck :(.
ViewController.swift
class ViewController: UIViewController {
#EnvironmentObject var UIModel: UIStateModel
var uiState: UIStateModel = UIStateModel()
override func viewDidLoad() {
super.viewDidLoad()
addCarousel()
}
func addCarousel(){
let snapC : SnapCarousel
let contentView : UIHostingController<SnapCarousel>
uiState = UIStateModel()
snapC = SnapCarousel( UIState: uiState )
contentView = UIHostingController(rootView: snapC)
addChild(contentView)
view.addSubview(contentView.view)
contentView.didMove(toParent: self)
}
}
Carousel.swift
struct SnapCarousel: View {
var UIState: UIStateModel
//Carousel instantiation
}
struct Card: Decodable, Hashable, Identifiable {
//code
}
public class UIStateModel: ObservableObject {
#Published var activeCard: Int = 0
#Published var screenDrag: Float = 0.0
}
struct Carousel<Items : View> : View {
#EnvironmentObject var UIState: UIStateModel
//card management
}
struct Canvas<Content : View> : View {
// view manager
}
struct Item<Content: View>: View {
#EnvironmentObject var UIState: UIStateModel
//code for card view
}
You need to register UIStateModel as an #EnvironmentObject so that it can be injected automatically in the subviews.
First, update SnapCarousel to hold the environmentObject:
struct SnapCarousel: View {
#EnvironmentObject var UIState: UIStateModel
//Carousel instantiation
}
To register model as environment object, when register with SnapCarousel as:
let snapC = SnapCarousel()
snapC.environmentObject(uiState)
If you don't need a reference to uiState, you can simplify the whole thing to:
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
addCarousel()
}
func addCarousel() {
let controller = UIHostingController(rootView: SnapCarousel().environmentObject(UIStateModel()))
addChild(controller)
view.addSubview(controller.view)
controller.didMove(toParent: self)
// add some constraints to fill the parent view
NSLayoutConstraint.activate([
controller.view.widthAnchor.constraint(equalTo: view.widthAnchor),
controller.view.heightAnchor.constraint(equalTo: view.heightAnchor),
controller.view.centerXAnchor.constraint(equalTo: view.centerXAnchor),
controller.view.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
}

Passing Value from SwiftUI to UIViewController Using UIHostingController

I have a SwiftUI view, which consists of a TextField. I want that whenever I type in the TextField it should send the value to the control on the UIKit UIViewController.
// Here is the ContentView
class ContentViewDelegate: ObservableObject {
var didChange = PassthroughSubject<ContentViewDelegate, Never>()
var name: String = "" {
didSet {
self.didChange.send(self)
}
}
}
struct ContentView: View {
#ObservedObject var delegate: ContentViewDelegate
init(delegate: ContentViewDelegate) {
self.delegate = delegate
}
var body: some View {
VStack {
TextField("Enter name", text: self.$delegate.name)
.textFieldStyle(RoundedBorderTextFieldStyle())
}.padding()
.background(Color.green)
}
}
I checked didChange does get fired in the above code. But in the code below, the sink is never fired.
class ViewController: UIViewController {
private var delegate = ContentViewDelegate()
private var contentView: ContentView!
override func viewDidLoad() {
super.viewDidLoad()
self.contentView = ContentView(delegate: self.delegate)
let controller = UIHostingController(rootView: self.contentView)
controller.view.translatesAutoresizingMaskIntoConstraints = false
self.addChild(controller)
self.view.addSubview(controller.view)
controller.didMove(toParent: self)
NSLayoutConstraint.activate([
controller.view.widthAnchor.constraint(equalToConstant: 200),
controller.view.heightAnchor.constraint(equalToConstant: 44),
controller.view.centerXAnchor.constraint(equalTo: self.view.centerXAnchor),
controller.view.centerYAnchor.constraint(equalTo: self.view.centerYAnchor)
])
_ = self.delegate.didChange.sink { delegate in
print(delegate.name)
}
}
Any ideas why didChange.sink is not getting fired?
If you assign the publisher to _ then it is deallocated when viewDidLoad returns. Early examples from Apple show the assignment to _ and it used to work.
You need to ensure you keep a strong reference to your publisher using a property:
class ViewController: UIViewController {
private var delegate = ContentViewDelegate()
private var contentView: ContentView!
private var textChangePublisher: AnyCancellable?
override func viewDidLoad() {
super.viewDidLoad()
self.contentView = ContentView(delegate: self.delegate)
let controller = UIHostingController(rootView: self.contentView)
controller.view.translatesAutoresizingMaskIntoConstraints = false
self.addChild(controller)
self.view.addSubview(controller.view)
controller.didMove(toParent: self)
NSLayoutConstraint.activate([
controller.view.widthAnchor.constraint(equalToConstant: 200),
controller.view.heightAnchor.constraint(equalToConstant: 44),
controller.view.centerXAnchor.constraint(equalTo: self.view.centerXAnchor),
controller.view.centerYAnchor.constraint(equalTo: self.view.centerYAnchor)
])
self.textChangePublisher = self.delegate.didChange.sink { delegate in
print(delegate.name)
}
}

How do you access UIViewControllerRepresentable NavigationBar property?

In SwiftUI if you are transitioning using a NavigationLink() into a UIViewControllerRepresentable how would you; say, add buttons or change the title property on the navigationbar.
This is what I am doing right now:
import SwiftUI
/// Controls the actual action performed by the button upon taps.
struct CatagoryButton: View {
#State var isPresenting :Bool = false
var company : Company?
var text : String
var body: some View {
NavigationLink(destination: UIKitWrapper(company: self.company, storyboardPointer: self.text)
.navigationBarTitle(self.text)
.edgesIgnoringSafeArea(.all),
isActive: self.$isPresenting,
label: {
Button(action: {
self.isPresenting.toggle()
}){
ZStack {
ButtonShadowLayer(text: text)
GradientBackground()
.mask(ButtonBaseLayer())
CircleAndTextLayer(text: text)
}
}
})
}
}
Here is the struct for my representable.
import SwiftUI
/// Wraps UIKIT instance in a representable that swiftUI can present.
struct UIKitWrapper: UIViewControllerRepresentable {
//Specify what type of controller is being wrapped in an associated type.
typealias UIViewControllerType = UIViewController
//Company property passed from parent view. Represents the company the user selected from main view.
private var company : Company
//Determines which viewcontroller will be presented to user. This string corresponds to the name of the storyboard file in the main bundle.
private var storyboardPointer : String
init(company: Company?, storyboardPointer: String) {
guard let company = company else {fatalError()}
self.company = company
self.storyboardPointer = storyboardPointer
}
func makeUIViewController(context: Context) -> UIViewControllerType {
//Find user defined storyboard in bundle using name.
let storyboard = UIStoryboard(name: storyboardPointer, bundle: .main)
//Downcast returned controller to protocol AccessControllerProtocol. This step is required because we are not sure which storyboard will be accessed. Potential storyboard controllers that can be called all conform to this protocol.
//FIXME: Remove fatalError and create error enum asap.
guard let viewController = storyboard.instantiateInitialViewController() as? AccessControllerProtocol else { fatalError() }
//Assign user selected company object to instance property on incoming viewController.
viewController.company = company
//Return UINavigationController with storyboard instance view controller as root controller.
return viewController
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
}
}
Finally, here is one of the classes that use the representable.
import UIKit
class OrdersViewController: UIViewController, AccessControllerProtocol {
var company : Company!
#IBOutlet var companyNameLabel : UILabel!
override func viewDidLoad() {
super.viewDidLoad()
setBackgroundColor()
companyNameLabel.text = company.name
self.navigationController?.navigationItem.rightBarButtonItems = [UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(self.tapRightBarButton))]
}
func setBackgroundColor(){
let backgroundGradient = BackgroundGradientSetter()
let viewWithGradient = backgroundGradient.setGradientToView(with: [DarkBlueHue_DEFAULT,LightBlueHue_DEFAULT], size: view.bounds)
view.addSubview(viewWithGradient)
view.sendSubviewToBack(viewWithGradient)
}
#objc func tapRightBarButton(){
}
}
No matter what I do this button doesn't show up. I'm not sure if I need to put this in a makeCoordinator() or if there is just something I am missing. If anyone has insight please let me know!
If it isn't available in viewDidLoad, try calling your setupNavigation() in viewWillAppear()
In your case navigationController is not available yet on viewDidLoad, try instead as in below demo module
Tested & works with Xcode 11.2 / iOS 13.2
class MyUIController: UIViewController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.navigationController?.navigationBar.topItem?.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(self.onAdd(_:)))
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
// might be needed to remove injected item here
}
#objc func onAdd(_ sender: Any?) {
print(">> tapped add")
}
}
struct MyInjector: UIViewControllerRepresentable {
func makeUIViewController(context: UIViewControllerRepresentableContext<MyInjector>) -> MyUIController {
MyUIController()
}
func updateUIViewController(_ uiViewController: MyUIController, context: UIViewControllerRepresentableContext<MyInjector>) {
}
}
struct DemoNavigationBarUIButton: View {
var body: some View {
NavigationView {
MyInjector()
.navigationBarTitle("Demo")
}
}
}
struct DemoNavigationBarUIButton_Previews: PreviewProvider {
static var previews: some View {
DemoNavigationBarUIButton()
}
}

Calling a local UIViewController function from a SwiftUI View

I'm trying to call a local ViewController function from ContentView. The function uses some local variables and cannot be moved outside the ViewController.
class ViewController: UIViewController {
func doSomething() {...}
}
extension ViewController : LinkViewDelegate {...}
located on a different file:
struct ContentView: View {
init() {
viewController = .init(nibName:nil, bundle:nil)
}
var viewController: viewController
var body: some View {
Button(action: {self.viewController.doSomething()}) {
Text("Link Account")
}
}
}
UIViewController cannot be changed to something like UIViewRepresentable because LinkViewDelegate can only extend UIViewController.
So you need to create a simple bool binding in SwiftUI, flip it to true to trigger the function call in the UIKit viewController, and then set it back to false until the next time the swiftUI button is pressed. (As for LinkViewDelegate preventing something like UIViewControllerRepresentable that shouldn't stop you, use a Coordinator to handle the delegate calls.)
struct ContentView: View {
#State var willCallFunc = false
var body: some View {
ViewControllerView(isCallingFunc: $willCallFunc)
Button("buttonTitle") {
self.willCallFunc = true
}
}
}
struct ViewControllerView: UIViewControllerRepresentable {
#Binding var isCallingFunc: Bool
func makeUIViewController(context: Context) -> YourViewController {
makeViewController(context: context) //instantiate vc etc.
}
func updateUIViewController(_ uiViewController: YourViewController, context: Context) {
if isCallingFunc {
uiViewController.doSomething()
isCallingFunc = false
}
}
}
Here is a way that I've come up with which doesn't result in the "Modifying state during view update, this will cause undefined behavior" problem. The trick is to pass a reference of your ViewModel into the ViewController itself and then reset the boolean that calls your function there, not in your UIViewControllerRepresentable.
public class MyViewModel: ObservableObject {
#Published public var doSomething: Bool = false
}
struct ContentView: View {
#StateObject var viewModel = MyViewModel()
var body: some View {
MyView(viewModel: viewModel)
Button("Do Something") {
viewModel.doSomething = true
}
}
}
struct MyView: UIViewControllerRepresentable {
#ObservedObject var viewModel: MyViewModel
func makeUIViewController(context: Context) -> MyViewController {
return MyViewController(viewModel)
}
func updateUIViewController(_ viewController: MyViewController, context: Context) {
if viewModel.doSomething {
viewController.doSomething()
// boolean will be reset in viewController
}
}
}
class MyViewController: UIViewController {
var viewModel: MyViewModel
public init(_ viewModel: MyViewModel) {
self.viewModel = viewModel
}
public func doSomething() {
// do something, then reset the flag
viewModel.doSomething = false
}
}
You could pass the instance of ViewController as a parameter to ContentView:
struct ContentView: View {
var viewController: ViewController // first v lowercase, second one Uppercase
var body: some View {
Button(action: { viewController.doSomething() }) { // Lowercase viewController
Text("Link Account")
}
}
init() {
self.viewController = .init(nibName:nil, bundle:nil) // Lowercase viewController
}
}
// Use it for the UIHostingController in SceneDelegate.swift
window.rootViewController = UIHostingController(rootView: ContentView()) // Uppercase ContentView
Updated answer to better fit the question.

Resources