SwiftUI preview fails when using UIHostingController when creating UIHostingController outside viewDidLoad - ios

I have some code that causes SwiftUI previews to fail but will run successfully in the simulator and on a device. The SwiftUI previews failure diagnostics message is:
PreviewUpdateTimedOutError: Updating took more than 5 seconds
Updating a preview from ViewControllerPreviews in NestedTest.app (9218) took more than 5 seconds.
I have boiled the issue down to a minimal amount of code:
import SwiftUI
import UIKit
let createHostInInit = true
class ViewController: UIViewController {
private var host: TestViewHost? = nil
init() {
if createHostInInit {
host = TestViewHost()
}
super.init(nibName: nil, bundle: nil)
}
// Note: this is here to satisfy the compiler and to allow running this
// in the simulator as part of a default new project.
required init?(coder: NSCoder) {
if createHostInInit {
host = TestViewHost()
}
super.init(coder: coder)
}
override func viewDidLoad() {
super.viewDidLoad()
if !createHostInInit {
host = TestViewHost()
}
if let host = self.host {
addChild(host)
self.view.addSubview(host.view)
host.didMove(toParent: self)
host.view.frame = self.view.bounds
}
}
}
class TestViewHost: UIHostingController<TestView> {
init() {
super.init(rootView: TestView())
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
struct TestView: View {
var body: some View {
Text("Hi there!")
}
}
struct UIViewControllerPreviewer: UIViewControllerRepresentable {
let viewController: UIViewController
init(viewController: UIViewController) {
self.viewController = viewController
}
func makeUIViewController(context: Context) -> some UIViewController {
return viewController
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
// Do nothing because this is only for previews
}
}
struct ViewControllerPreviews: PreviewProvider {
static var previews: some View {
return UIViewControllerPreviewer(viewController: ViewController())
}
}
If createHostInInit = true then the previews fail. If createHostInInit = false then the previews work. In either of these cases the UI looks correct in the simulator or on a device. So it would seem that something about the preview environment gets cranky about when a UIHostingController is created before viewDidLoad().
Did I miss some documentation for UIHostingController describing these limitations? Is this a bug?
This can be tested in Xcode by creating a new iOS single view project and dropping this code into ViewController.swift.
Thanks

Related

navigationTitle not working with UINavigationController inside a TabView in iOS 16

Setting a navigationTitle is not working anymore on iOS 16 when having UINavigationController inside a TabView. Run the code with iOS 14/15, no issue there. If Tabview is commented, navigation title appears for iOS 16 too. It seems the problem is caused somehow by the TabView. I know I can send the title as a parameter but I would prefer not to, also, for the moment, switching to NavigationVies is not an option.
import SwiftUI
#main
struct CustomUIKitNavigationApp: App {
var body: some Scene {
WindowGroup {
TabView {
NavigationViewControllerRepresentable {
VStack {
Text("why navigation title is not working anymore on iOS 16 when in TabView?")
.navigationTitle("navigation is not appearing")
}
}
}
}
}
}
struct NavigationViewControllerRepresentable<Content: View>: UIViewControllerRepresentable {
let nav = UINavigationController()
init(#ViewBuilder content: #escaping () -> Content) {
let vc = HostingController(content: AnyView(content()))
nav.addChild(vc)
}
func makeUIViewController(context: Context) -> UINavigationController {
return nav
}
func updateUIViewController(_ pageViewController: UINavigationController, context: Context) {}
}
class HostingController: UIHostingController<AnyView> {
init(content: AnyView) {
super.init(rootView: AnyView(content))
}
#objc required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) not implemented")
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
}
}

How do I implement data binding in a View Controller in SwiftUI?

I have a UIKit ViewController that's nested inside a SwiftUI view using ViewControllerRepresentable. The SwiftUI view manages a bit of state (an Int, in this example) that I want to display in the UIKit view. When the user taps a button in the SwiftUI parent view, the state change should be reflected in the UIKit view. I've tried using the #Binding property wrapper to keep the two in sync, but clearly I'm missing something, as my view controller's initialiser throws a compile-time error.
I'm quite new to iOS development so perhaps I'm going in the complete wrong direction here. Any help would be greatly appreciated.
The code is as follows (simplified):
struct ContentView: View {
#State private var currentNumber: Int
init(currentNumber: Int) {
self.currentNumber = currentNumber
}
var body: some View {
FancyLabelViewControllerRepresentable(currentNumber: self.$currentNumber)
Button("Increment") {
self.currentNumber += 1
}
}
}
struct FancyLabelViewControllerRepresentable: UIViewControllerRepresentable {
typealias UIViewControllerType = FancyLabelViewController
#Binding var currentNumber: Int
init(currentNumber: Binding<Int>) {
self._currentNumber = currentNumber
}
func makeUIViewController(context: Context) -> FancyLabelViewController {
let fancyLabel = FancyLabelViewController(number: self.currentNumber)
fancyLabel.currentNumberInLabel = self.currentNumber
return fancyLabel
}
func updateUIViewController(_ uiViewController: FancyLabelViewController, context: Context) {
uiViewController.currentNumberInLabel = self.currentNumber
}
}
class FancyLabelViewController: UIViewController {
var label = UILabel()
#Binding var currentNumberInLabel: Int
init(number: Int) {
// Error: 'self' used in property access 'currentNumberInLabel' before 'super.init' call
self.currentNumberInLabel = number
// Error: Property 'self.currentNumberInLabel' not initialized at super.init call
super.init(nibName: nil, bundle: nil)
}
required init(coder: NSCoder) {
fatalError("Not implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
label.text = "\(currentNumberInLabel)"
view = label
}
}
I think you don't need the
#Binding var currentNumberInLabel: Int
because the UIViewControllerRepresentable already takes care of updating the currentNumberInLabel value, but you also needs to update the
label.text = "\(currentNumberInLabel)"
So I did something like
class FancyLabelViewController: UIViewController {
var label = UILabel()
var currentNumberInLabel: Int
init(number: Int) {
self.currentNumberInLabel = number
super.init(nibName: nil, bundle: nil)
}
required init(coder: NSCoder) {
fatalError("Not implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
label.text = "\(currentNumberInLabel)"
view = label
}
func updateLabel() {
label.text = "\(currentNumberInLabel)"
}
}
and call updateLabel from UIViewControllerRepresentable as
func updateUIViewController(_ uiViewController: FancyLabelViewController, context: Context) {
uiViewController.currentNumberInLabel = self.currentNumber
uiViewController.updateLabel()
}

How to set #EnvironmentObject in an existing Storyboard based project?

I have an iOS project built with Storyboard and UIKit. Now I want to develop the new screens using SwiftUI. I added a Hosting View Controller to the existing Storyboard and used it to show my newly created SwiftUI view.
But I couldn't figure out how to create an #EnvironmenetObject that can be used anywhere throughout the application. I should be able to access/set it in any of my UIKit based ViewController as well as my SwiftUI views.
Is this possible? If so how to do it? In a pure SwiftUI app, we set the environment object like below,
#main
struct myApp: App {
#StateObject var item = Item()
var body: some Scene {
WindowGroup {
MainView()
.environmentObject(item)
}
}
}
But in my case, there is no function like this since it is an existing iOS project with AppDelegate and SceneDelegate. And the initial view controller is marked in Storyboard.
How to set this and access the object anywhere in the app?
The .environmentObject modifier changes the type of the view from ItemDetailView to something else. Force casting it will cause an error. Instead, try wrapping it into an AnyView.
class OrderObservable: ObservableObject {
#Published var order: String = "Hello"
}
struct ItemDetailView: View {
#EnvironmentObject var orderObservable: OrderObservable
var body: some View {
EmptyView()
.onAppear(perform: {
print(orderObservable.order)
})
}
}
class ItemDetailViewHostingController: UIHostingController<AnyView> {
let appDelegate = UIApplication.shared.delegate as! AppDelegate
required init?(coder: NSCoder) {
super.init(coder: coder,rootView: AnyView(ItemDetailView().environmentObject(OrderObservable())))
}
}
This works for me. Is this what you require?
EDIT:
Ok, so I gave the setting the property from a ViewController all through the View. It wasn't as easy as using a property wrapper or a view modifier, but it works. I gave it a spin. Please let me know if this satisfies your requirement. Also, I had to get rid of the HostingController subclass.
class ViewController: UIViewController {
var orderObservable = OrderObservable()
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
guard let myVC = (segue.destination as? MyViewController) else { return }
myVC.orderObservable = orderObservable
}
}
class MyViewController: UIViewController {
var orderObservable: OrderObservable!
var anycancellables = Set<AnyCancellable>()
#IBAction #objc func buttonSegueToHostingVC() {
let detailView = ItemDetailView().environmentObject(orderObservable)
present(UIHostingController(rootView: detailView), animated: true)
orderObservable.$order.sink { newVal in
print(newVal)
}
.store(in: &anycancellables)
}
}
class OrderObservable: ObservableObject {
#Published var order: String = "Hello"
init() {
DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
self.order = "World"
}
}
}
struct ItemDetailView: View {
#EnvironmentObject var orderObservable: OrderObservable
var body: some View {
Text("\(orderObservable.order)")
}
}
Basically I'm creating the observable object in the ViewController class, passing it to the MyViewController class and finally create a hosting controller with the ItemDetailView and setting it's environmentObject and presenting it.
Here's my take on tackling this problem. My app targets iOS 14 or above:
The current state
I have a Main.storyboard file with one view controller scene set as the initial view controller with custom class ViewController. Here's the custom class implementation:
import UIKit
class ViewController: UIViewController {
#IBOutlet var label: UILabel!
}
The goal
To use this class in a SwiftUI app life cycle and make it react and interact to #EnvironmentObject instance (In this case let's call it a theme manager).
Solution
I will define a ThemeManager observable object with a Theme published property like so:
import SwiftUI
class ThemeManager: ObservableObject {
#Published var theme = Theme.purple
}
struct Theme {
let labelColor: Color
}
extension Theme {
static let purple = Theme(labelColor: .purple)
static let green = Theme(labelColor: .green)
}
extension Theme: Equatable {}
Next, I created a ViewControllerRepresentation to be able to use the ViewController in SwiftUI:
import SwiftUI
struct ViewControllerRepresentation: UIViewControllerRepresentable {
#EnvironmentObject var themeManager: ThemeManager
// Use this function to pass the #EnvironmentObject to the view controller
// so that you can change its properties from inside the view controller scope.
func makeUIViewController(context: Context) -> ViewController {
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let viewController = storyboard.instantiateInitialViewController { coder in
ViewController(themeManager: themeManager, coder: coder)
}
return viewController!
}
// Use this function to update the view controller when the #EnvironmentObject changes.
// In this case I modify the label color based on the themeManager.
func updateUIViewController(_ uiViewController: ViewController, context: Context) {
uiViewController.label.textColor = UIColor(themeManager.theme.labelColor)
}
}
I then updated ViewController to accept a themeManager instance:
import UIKit
class ViewController: UIViewController {
#IBOutlet var label: UILabel!
let themeManager: ThemeManager
init?(themeManager: ThemeManager, coder: NSCoder) {
self.themeManager = themeManager
super.init(coder: coder)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
#IBAction func toggleTheme(_ sender: UIButton) {
if themeManager.theme == .purple {
themeManager.theme = .green
} else {
themeManager.theme = .purple
}
}
}
Now, the last thing to do is create an instance of the theme manager and pass it as an environment object to the view controller representation:
import SwiftUI
#main
struct ThemeEnvironmentApp: App {
#StateObject private var themeManager = ThemeManager()
var body: some Scene {
WindowGroup {
ViewControllerRepresentation()
.environmentObject(themeManager)
}
}
}
Running the app shows our view controller with a label and a button. Tapping the button triggers the IBAction, which changes the themeManager.theme, which triggers a call to the representation's updateUIViewController(_:, context:):

Delegate for SwiftUI View from Hosting View Controller

I add UIHostingViewController to my Storyboard and make class for this.
Load mySwiftUIView in controller init. Everything works good.
But i want to make hosting controller like delegate for mySwiftUIView because I want to handle button pressing in view.
required init?(coder: NSCoder) {
var mySwiftView = MySwiftUIView()
mySwiftView.delegate = self //self used before super.init call
super.init(coder: coder,rootView: mySwiftView)
}
Id doesn't work because I pass view before hosting controller fully init. Swift shows message "self used before super.init call"
If I will use usual UIViewController and put HostingController inside - it is works. But this is not suitable for me because it calls problem with sizes and navigation.
How can I do it with separate UIHostingController. Thanks
Full code
import UIKit
import SwiftUI
protocol MySwiftUIViewDelegate{
func buttonPressed()
}
struct MySwiftUIView: View {
var delegate: MySwiftUIViewDelegate?
var body: some View {
Button(action: {
delegate?.buttonPressed()
}) {
Text("My button")
}
}
}
class MyHostingViewController: UIHostingController<MySwiftUIView>, MySwiftUIViewDelegate {
required init?(coder: NSCoder) {
var mySwiftView = MySwiftUIView()
//mySwiftView.delegate = self //self used before super.init call
super.init(coder: coder,rootView: mySwiftView)
}
override func viewDidLoad() {
super.viewDidLoad()
}
func buttonPressed() {
performSegue(withIdentifier: "GoToOtherScreenSegue", sender: self)
}
}
Since MyHostingViewController knows that its rootView is MySwiftUIView, you could assign the view controller as the delegate afterwards:
required init?(coder: NSCoder) {
var mySwiftView = MySwiftUIView()
super.init(coder: coder, rootView: mySwiftView)
rootView.delegate = self
}
You can wrap delegate into class
import UIKit
import SwiftUI
protocol MySwiftUIViewDelegate{
func buttonPressed()
}
struct MySwiftUIView: View {
let configuration: Configuration
var body: some View {
Button(action: {
configuration.delegate?.buttonPressed()
}) {
Text("My button")
}
}
}
extension MySwiftUIView {
final class Configuration {
unowned var delegate: MySwiftUIViewDelegate?
}
}
class MyHostingViewController: UIHostingController<MySwiftUIView>, MySwiftUIViewDelegate {
required init?(coder: NSCoder) {
let configuration = MySwiftUIView.Configuration()
let mySwiftView = MySwiftUIView(configuration: configuration)
super.init(coder: coder,rootView: mySwiftView)
configuration.delegate = self
}
override func viewDidLoad() {
super.viewDidLoad()
}
func buttonPressed() {
performSegue(withIdentifier: "GoToOtherScreenSegue", sender: self)
}
}

How can I change the status bar text color per view in SwiftUI?

I'm looking for a way to change the text color of the status bar that allows a different text color to be used for each view.
I've seen this Q&A, but it's not what I'm looking for. I'm not looking for solutions that only allow for one status bar text color for all views. I want to change the status bar text color for each view. For example, one view might have a dark background and so I need light text. I might navigate to another view with a light background, so now I need dark text. The suggested duplicate answer only returns .lightContent, which means that the status bar text color cannot change dynamically when I move to a different view.
This answer here works on my machine, but it's not performant. A comment under it corroborates this. The lag is unacceptable, so this solution is not good.
Other solutions I've seen so far cause this particular error:
Compiling failed: extensions of generic classes cannot contain '#objc' members
I've also tried using an Environment Object inside my Custom Controller:
import SwiftUI
/// Allows for the status bar colors to be changed from black to white on the dark gray title bar
class Controller<ContentView> : UIHostingController<ContentView> where ContentView : View {
#EnvironmentObject var statusBarTextColor: StatusBarTextColor
lazy var isDark: Bool = self.statusBarTextColor.isDark
override var preferredStatusBarStyle: UIStatusBarStyle {
return isDark ? .lightContent : .darkContent
}
}
This results in the error:
Thread 1: Fatal error: No ObservableObject of type StatusBarTextColor found. A View.environmentObject(_:) for StatusBarTextColor may be missing as an ancestor of this view.
Inside my SceneDelegate file, I do specify the StatusBarTextColor environmentObject:
window.rootViewController = Controller(
rootView: Home()
.environmentObject(PostData())
.environmentObject(CardPosition())
.environmentObject(StatusBarTextColor())
)
And this is the ObservableObject itself:
import Combine
import SwiftUI
final class StatusBarTextColor: ObservableObject {
#Published var isDark: Bool = true
}
If I were to guess why this doesn't work, I'd say it's because the Controller gets initialized before StatusBarTextColor is available.
The more I look into this problem, the more I think there isn't a solution. I've gone through just about every article, answer, and video on the subject. They all either use a Controller to only return .lightContent, or use storyboards and multiple controllers, which isn't what I'm using.
You can use the solution you found here, but instead of using onDisappear, which will have a delay for the color change until the view is completely gone, you can create a view modifier called onWillDisappear that exposes viewWillDisappear. The color change will happen as sooner.
Usage:
struct MyClass: View {
#Environment(\.localStatusBarStyle) var statusBarStyle
// ...
SomeView()
}.onAppear {
self.statusBarStyle.currentStyle = .darkContent
}
.onWillDisappear {
self.statusBarStyle.currentStyle = .lightContent
}
}
Code:
import SwiftUI
class HostingController<Content>: UIHostingController<Content> where Content: View {
private var internalStyle = UIStatusBarStyle.lightContent
#objc override dynamic open var preferredStatusBarStyle: UIStatusBarStyle {
get {
internalStyle
}
set {
internalStyle = newValue
self.setNeedsStatusBarAppearanceUpdate()
}
}
override init(rootView: Content) {
super.init(rootView:rootView)
LocalStatusBarStyleKey.defaultValue.getter = { self.preferredStatusBarStyle }
LocalStatusBarStyleKey.defaultValue.setter = { self.preferredStatusBarStyle = $0 }
}
#objc required dynamic init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
}
class LocalStatusBarStyle { // style proxy to be stored in Environment
fileprivate var getter: () -> UIStatusBarStyle = { .default }
fileprivate var setter: (UIStatusBarStyle) -> Void = {_ in}
var currentStyle: UIStatusBarStyle {
get { self.getter() }
set { self.setter(newValue) }
}
}
// Custom Environment key, as it is set once, it can be accessed from anywhere
// of SwiftUI view hierarchy
struct LocalStatusBarStyleKey: EnvironmentKey {
static let defaultValue: LocalStatusBarStyle = LocalStatusBarStyle()
}
extension EnvironmentValues { // Environment key path variable
var localStatusBarStyle: LocalStatusBarStyle {
get {
return self[LocalStatusBarStyleKey.self]
}
}
}
struct WillDisappearHandler: UIViewControllerRepresentable {
func makeCoordinator() -> WillDisappearHandler.Coordinator {
Coordinator(onWillDisappear: onWillDisappear)
}
let onWillDisappear: () -> Void
func makeUIViewController(context: UIViewControllerRepresentableContext<WillDisappearHandler>) -> UIViewController {
context.coordinator
}
func updateUIViewController(_ uiViewController: UIViewController, context: UIViewControllerRepresentableContext<WillDisappearHandler>) {
}
typealias UIViewControllerType = UIViewController
class Coordinator: UIViewController {
let onWillDisappear: () -> Void
init(onWillDisappear: #escaping () -> Void) {
self.onWillDisappear = onWillDisappear
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
onWillDisappear()
}
}
}
struct WillDisappearModifier: ViewModifier {
let callback: () -> Void
func body(content: Content) -> some View {
content
.background(WillDisappearHandler(onWillDisappear: callback))
}
}
extension View {
func onWillDisappear(_ perform: #escaping () -> Void) -> some View {
self.modifier(WillDisappearModifier(callback: perform))
}
}
See original post with onWillDisappear code here
In your SceneDelegate you inject StatusBarTextColor() to the Home view. However, you declared the EvironmentObject in Controller.

Resources