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

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()
}

Related

Is it ok to call closures in UIKit UIViewController from Hosted SwiftUI View?

I'm hosting ContentView in WrapperViewController like this:
class WrapperViewController: UIViewController {
init() {
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
self.view = UIView()
var contentView = ContentView()
contentView.buttonTapped = { [weak self] in
self?.tappedButton()
}
let contentViewController = UIHostingController(rootView: contentView)
self.addChild(contentViewController)
self.view.addSubview(contentViewController.view)
contentViewController.view.frame = view.bounds
contentViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
contentViewController.didMove(toParent: self)
}
func tappedButton() {
print("tapped button")
}
}
struct ContentView: View {
var buttonTapped: (() -> Void)?
var body: some View {
Button("Tap me") {
buttonTapped?()
}
}
}
I'm calling buttonTapped in the WrapperViewController from ContentView, but is this fine? "tapped button" is getting printed, but I read this article that said
When you use closures in structs, the closure behaves as a reference type, and the problem starts there. The closures need to have a reference to the environment outside so that the environment can be modified when the closure body is executed.
struct Car {
var speed: Float = 0.0
var increaseSpeed: (() -> ())?
}
var myCar = Car()
myCar.increaseSpeed = {
myCar.speed += 30 // The retain cycle occurs here. We cannot use [weak myCar] as myCar is a value type.
}
So should I use a closure here? Or am I supposed to do something else?
We cannot use [weak myCar] as myCar is a value type.
In the example from the article, myCar is a struct.
However, in your code:
contentView.buttonTapped = { [weak self] in
self?.tappedButton()
}
self is a UIViewController which is a class (and a reference type).
Your use of [weak self] is perfectly fine: the closure will keep a weak reference to the UIViewController and when you tap a button, the tappedButton() function will be called. Which is probably what you expect.

Using PKToolPicker without PKCanvasView

I am trying to use PKToolPicker from PencilKit (iOS/Swift) from a custom view (which is NOT PKCanvasView). The custom view conforms to PKToolPickerObserver. Everything works fine during compile time but I never get to see the PKToolPicker! If I replace my custom view with PKCanvasView, everything works fine!
I am doing this in SwiftUI with UIViewRepresentable (thus First Responder seems a mystery!).
Here is the SwiftUI view in question:
struct PencilKitView: UIViewRepresentable {
typealias UIViewType = myView
let coordinator = Coordinator()
class Coordinator: NSObject, PKToolPickerObserver {
func toolPickerSelectedToolDidChange(_ toolPicker: PKToolPicker) {
// some code
}
func toolPickerVisibilityDidChange(_ toolPicker: PKToolPicker) {
// some code
}
}
func makeCoordinator() -> PencilKitView.Coordinator {
return Coordinator()
}
func makeUIView(context: Context) -> myView {
let canvasView = myView()
canvasView.isOpaque = false
canvasView.backgroundColor = UIColor.clear
canvasView.becomeFirstResponder()
if let window = UIApplication.shared.windows.filter({$0.isKeyWindow}).first,
let toolPicker = PKToolPicker.shared(for: window) {
toolPicker.addObserver(canvasView)
toolPicker.addObserver(coordinator)
toolPicker.setVisible(true, forFirstResponder: canvasView)
}
return canvasView
}
func updateUIView(_ uiView: myView, context: Context) {
}
}
If I replace myView with PKCanvasView above, the PKToolPicker will be seen.
For the sake of completeness, here is the MyView stub:
class myView: UIScrollView, PKToolPickerObserver {
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
public func toolPickerVisibilityDidChange(_ toolPicker: PKToolPicker) {
/// some code
}
public func toolPickerSelectedToolDidChange(_ toolPicker: PKToolPicker) {
/// some code
}
public func toolPickerIsRulerActiveDidChange(_ toolPicker: PKToolPicker) {
/// some code
}
public func toolPickerFramesObscuredDidChange(_ toolPicker: PKToolPicker) {
/// some code
}
}
Anyone has succeeded in doing this? Is there some undocumented requirement for adopting PKToolPicker?
Here is simplest demo to show PKToolPicker for any custom UIView in SwiftUI.
Tested with Xcode 11.4 / iOS 13.4
struct ToolPickerDemo: View {
#State private var showPicker = false
var body: some View {
Button("Picker") { self.showPicker.toggle() }
.background(ToolPickerHelper(isActive: $showPicker))
}
}
class PickerHelperView: UIView {
override var canBecomeFirstResponder: Bool { true }
}
struct ToolPickerHelper: UIViewRepresentable {
#Binding var isActive: Bool
func makeUIView(context: Context) -> PickerHelperView {
PickerHelperView()
}
func updateUIView(_ uiView: PickerHelperView, context: Context) {
guard let window = uiView.window else { return }
let picker = PKToolPicker.shared(for: window)
picker?.setVisible(isActive, forFirstResponder: uiView)
DispatchQueue.main.async {
uiView.becomeFirstResponder()
}
}
}

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.

SwiftUI View Not Updating to Reflect Data Changes in UIViewController

I've been experimenting with SwiftUI and UIKit, trying to understand how data is shared between the two frameworks, and I've created a simple example for a larger project I am working on. The example is a single SwiftUI view that contains a UIViewControllerRepresentatable wrapping a custom view controller. I am trying to have the SwiftUI view display the value of one of the view controller's properties, but it does not refresh correctly when the value is changed.
struct ContentView: View {
#State var viewController = MyViewControllerRepresentable()
var body: some View {
VStack {
viewController
Text("super special property: \(viewController.viewController.data)")
}
}
}
class MyViewController: UIViewController, ObservableObject {
#Published var data = 3
override func viewDidLoad() {
let button = UIButton(type: .system)
button.setTitle("Increase by 1", for: .normal)
button.addTarget(self, action: #selector(buttonPressed), for: .touchUpInside)
view = button
}
#objc func buttonPressed() {
data += 1
}
}
struct MyViewControllerRepresentable: UIViewControllerRepresentable {
#ObservedObject var viewController = MyViewController()
func makeUIViewController(context: Context) -> UIViewController {
return self.viewController
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}
When I run the app and press the button, I can see that the actual value of data is changing, and the publisher in MyViewController is firing, but the value displayed on screen is not refreshed to reflect this.
Please note, I am very new to iOS development, and this is probably an unconventional data model. However, I don't see why it shouldn't work correctly. Suggestions for a better way to share data would be much appreciated, but I would primarily like to know if it is possible to get this working with its current data structure.
Thank you in advance.
You could create a #Binding. This means that when the value is updated for data, the views are recreated to reflect the changes.
Here is how it can be done:
struct ContentView: View {
#State private var data = 3
var body: some View {
VStack {
MyViewControllerRepresentable(data: $data)
Text("super special property: \(data)")
}
}
}
class MyViewController: UIViewController {
#Binding private var data: Int
init(data: Binding<Int>) {
self._data = data
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
let button = UIButton(type: .system)
button.setTitle("Increase by 1", for: .normal)
button.addTarget(self, action: #selector(buttonPressed), for: .touchUpInside)
view = button
}
#objc func buttonPressed() {
data += 1
}
}
struct MyViewControllerRepresentable: UIViewControllerRepresentable {
#Binding var data: Int
private let viewController: MyViewController
init(data: Binding<Int>) {
self._data = data
viewController = MyViewController(data: data)
}
func makeUIViewController(context: Context) -> UIViewController {
viewController
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}

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()
}
}

Resources