Accessing Value of SwiftUI View through a UIKit View - ios

I read this question online and found it to be very interesting. If you have a SwiftUI view as shown below. How can you access the selected rating in a UIKit view without changing a single line in RatingView.
struct RatingView: View {
#Binding var rating: Int?
private func starType(index: Int) -> String {
if let rating = rating {
return index <= rating ? "star.fill" : "star"
} else {
return "star"
}
}
var body: some View {
HStack {
ForEach(1...5, id: \.self) { index in
Image(systemName: self.starType(index: index))
.foregroundColor(Color.orange)
.onTapGesture {
rating = index
}
}
}
}
}
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
self.view.backgroundColor = .white
// .constant(3) will be replaced by something in the ViewController so it can handle the changes for the rating
let hostingController = UIHostingController(rootView: RatingView(rating: .constant(3)))
guard let ratingsView = hostingController.view else { return }
self.addChild(hostingController)
self.view.addSubview(ratingsView)
}
UPDATE:
Original source: https://twitter.com/azamsharp/status/1540838477599752192?s=20&t=bbDp3VT9m0Ce4W3Sgr7Iyg
I typed most of the code from the original source. I did not change much.

We can decorate the RatingView and use an ObservableObject to hold on to the source of truth.
class RatingObserver: ObservableObject {
#Published var rating: Int?
}
struct WrappedRatingView: View {
#ObservedObject var ratingObserver: RatingObserver
var body: some View {
RatingView(rating: $ratingObserver.rating)
}
}
Then we can use it in the following way.
class ViewController: UIViewController {
let ratingObserver = RatingObserver()
override func viewDidLoad() {
super.viewDidLoad()
self.view.backgroundColor = .white
let hostingController = UIHostingController(
rootView: WrappedRatingView(ratingObserver: ratingObserver)
)
self.addChild(hostingController)
view.addSubview(hostingController.view)
hostingController.didMove(toParent: self)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
let button = UIButton()
button.translatesAutoresizingMaskIntoConstraints = false
button.setTitle("Reset Rating", for: .normal)
button.setTitleColor(.blue, for: .normal)
button.addTarget(self, action: #selector(resetRating), for: .touchUpInside)
view.addSubview(button)
NSLayoutConstraint.activate([
hostingController.view.centerXAnchor.constraint(equalTo: view.centerXAnchor),
hostingController.view.centerYAnchor.constraint(equalTo: view.centerYAnchor),
button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
button.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: 100)
])
}
#objc func resetRating() {
ratingObserver.rating = nil
}
}
This allows for updating both the ViewController and the SwiftUI view.

Related

Conditionally assigning a view of type UIViewControllerRepresentable fails to render changes when State is changed

Pressing the "FIRST" or "SECOND" Text views should change the State selection and update selectedView accordingly. While the debugger shows that pressing "FIRST" or "SECOND" causes body to be re-evaluated and correctly assigns selectedView, the screen never updates to show the correct view. Instead, the screen only shows the view that corresponds to selection's initialized state and remains stuck on that view.
import UIKit
import SwiftUI
struct TestView: View {
#State private var selection: Int = 1
var body: some View {
let firstView = TestRepresentable(string: "First View")
let secondView = TestRepresentable(string: "Second View")
let selectedView = selection == 1 ? firstView : secondView
let finalView = VStack {
HStack {
Text("FIRST").onTapGesture { self.selection = 1 }.padding()
Text("SECOND").onTapGesture { self.selection = 2 }.padding()
}
selectedView
}
return finalView
}
}
struct TestRepresentable: UIViewControllerRepresentable {
let string: String
func makeUIViewController(context: Context) -> TestVC {
return TestVC().create(string)
}
func updateUIViewController(_ uiViewController: TestVC, context: Context) {
//...
}
func makeCoordinator() -> Coordinator {
Coordinator(testRepresentable: self)
}
class Coordinator: NSObject {
var testRepresentable: TestRepresentable
init(testRepresentable: TestRepresentable) {
self.testRepresentable = testRepresentable
}
}
}
class TestVC: UIViewController {
private let label = UILabel()
override func loadView() {
super.loadView()
view.addSubview(label)
label.translatesAutoresizingMaskIntoConstraints = false
label.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
label.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
label.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
label.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
}
func create(_ string: String) -> TestVC {
label.text = string
return self
}
}
When I swap TestRepresentable(string: "First View") with Text("First View") (and do the same for "Second View"), the screen updates correctly. Something about my UIViewController or UIViewControllerRepresentable implementations prevent changes in my SwiftUI view from rendering on the screen. Why don't changes to the State variable selection render the correct selectedView?
It is just not a SwiftUI coding (so body could not correctly track state changes), here is a fixed part of code (tested with Xcode 13 / iOS 15)
var body: some View {
VStack {
HStack {
Text("FIRST").onTapGesture { self.selection = 1 }.padding()
Text("SECOND").onTapGesture { self.selection = 2 }.padding()
}
if selection == 1 {
TestRepresentable(string: "First View")
} else {
TestRepresentable(string: "Second View")
}
}
}

Is it possible to assign a ViewController to a SwiftUI view?

I am building an SwiftUI app with the SwiftUI app protocol.
I need to access some UIKIt functions, particularly the ones that control NavigationView as in here: https://stackoverflow.com/a/56555928/13727105.
Apparently, to do that I need to bind my SwiftUI view with a ViewController.
I've tried doing the following:
ContentView.swift
struct ContentView: View {
var body: some View {
ZStack {
ContentViewIntegratedController() // <- here
NavigationView{
ScrollView {
Text("Content View")
.navigationTitle("Content View")
}
}
}
}
}
class ContentViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
}
}
struct ContentViewIntegratedController: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> some UIViewController {
return ContentViewController()
}
func updateUIViewController(_ uiViewController: UIViewControllerType,
context: UIViewControllerRepresentableContext<ContentViewIntegratedController>) {}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
but calling ContentViewIntegratedContoller (on line 5) seems to create a new preview instead of modifying the existing one.
Is there anyway to integrate a ViewController to modify a SwiftUI view?
What I don't want to do:
Create a Storyboards app with SwiftUI views in it
Create a Storyboards view for ContentViewIntegratedViewController.
Is there any alternative ways I could access those functions without adding a ViewController to my SwiftUI view?
TIA.
struct ContentView: View {
var body: some View {
ContentViewIntegratedController(view: YourView())
.ignoresSafeArea()
}
}
--------------------------------------------------------------
struct YourView: View {
var body: some View {
ScrollView{
Text("Hello, World!")
}
}}
------------------------------------------------------
import Foundation
import SwiftUI
struct ContentViewIntegratedController :UIViewControllerRepresentable{
var view: YourView
init(view:YourView) {
self.view = view
}
func makeUIViewController(context: Context) -> UINavigationController{
let childView = UIHostingController(rootView: view)
let controller = UINavigationController(rootViewController:childView)
let appearance = UINavigationBarAppearance()
let searchController = UISearchController()
searchController.searchBar.barStyle = .black
appearance.backgroundColor = UIColor(Color(.red))
appearance.largeTitleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white]
appearance.titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white]
controller.navigationBar.topItem?.compactAppearance = appearance
controller.navigationBar.topItem?.scrollEdgeAppearance = appearance
controller.navigationBar.topItem?.standardAppearance = appearance
controller.navigationBar.topItem?.title = "navigation bar"
controller.navigationBar.prefersLargeTitles = true
searchController.searchBar.searchTextField.attributedPlaceholder = NSAttributedString(string: "Rechercher...", attributes: [NSAttributedString.Key.foregroundColor: UIColor.white])
searchController.searchBar.setValue("Annuler", forKey: "cancelButtonText")
searchController.searchBar.showsBookmarkButton = true
searchController.searchBar.searchTextField.leftView?.tintColor = .white
let sfConfiguration = UIImage.SymbolConfiguration(pointSize: 30)
let barCodeIcon = UIImage(systemName: "barcode.viewfinder")?.withTintColor(.white, renderingMode: .alwaysOriginal).withConfiguration(sfConfiguration)
searchController.searchBar.setImage(barCodeIcon, for: .bookmark, state:.normal)
searchController.obscuresBackgroundDuringPresentation = false
let attributes = [NSAttributedString.Key.foregroundColor : UIColor.white]
UIBarButtonItem.appearance(whenContainedInInstancesOf: [UISearchBar.self]).setTitleTextAttributes(attributes, for: .normal)
controller.navigationBar.topItem?.hidesSearchBarWhenScrolling = false
controller.navigationBar.topItem?.searchController = searchController
return controller
}
func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {
}}
There is no way in pure SwiftUI to directly detect the changing between large and inline display modes. However, using SwiftUI-Introspect, you can drop down into UIKit to solve this.
I solved this by getting the view for the large title, and detecting when the alpha property changed. This changes at the exact moment when the title changes from large to inline, or vice-versa.
Code:
struct ContentView: View {
#State private var observer: NSKeyValueObservation?
#State private var title: String = "Large Title"
var body: some View {
NavigationView {
ScrollView {
Text("Hello world!")
}
.navigationTitle(title)
}
.introspectNavigationController { nav in
let largeTitleView = nav.navigationBar.subviews.first { view in
String(describing: type(of: view)) == "_UINavigationBarLargeTitleView"
}
observer = largeTitleView?.observe(\.alpha) { view, change in
let isLarge = view.alpha == 1
title = isLarge ? "Large Title" : "Inline title"
}
}
}
}
Result:

How to bind SwiftUI and UIViewController behavior

I have a UIKit project with UIViewControllers, and I'd like to present an action sheet built on SwiftUI from my ViewController. I need to bind the appearance and disappearance of the action sheet back to the view controller, enabling the view controller to be dismissed (and for the display animation to happen only on viewDidAppear, to avoid some weird animation behavior that happens when using .onAppear). Here is a code example of how I expect the binding to work and how it's not doing what I'm expecting:
import UIKit
import SwiftUI
class ViewController: UIViewController {
let button = UIButton(type: .system)
var show = true
lazy var isShowing: Binding<Bool> = .init {
self.show
} set: { show in
// This code gets called
self.show = show
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
button.setTitle("TAP THIS BUTTON", for: .normal)
view.addSubview(button)
button.translatesAutoresizingMaskIntoConstraints = false
button.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
button.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
button.addTarget(self, action: #selector(tapped), for: .touchUpInside)
}
#objc private func tapped() {
let vc = UIHostingController(rootView: BindingProblemView(testBinding: isShowing))
vc.modalPresentationStyle = .overCurrentContext
present(vc, animated: false)
DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [self] in
isShowing.wrappedValue.toggle()
isShowing.update()
}
}
}
struct BindingProblemView: View {
#Binding var testBinding: Bool
#State var state = "ON"
var body: some View {
ZStack {
if testBinding {
Color.red.ignoresSafeArea().padding(0)
} else {
Color.green.ignoresSafeArea().padding(0)
}
Button("Test Binding is \(state)") {
testBinding.toggle()
}.onChange(of: testBinding, perform: { value in
// This code never gets called
state = testBinding ? "ON" : "OFF"
})
}
}
}
What happens is that onChange never gets called after viewDidAppear when I set the binding value true. Am I just completely misusing the new combine operators?
You can pass the data through ObservableObjects, rather than with Bindings. The idea here is that ViewController has the reference to a PassedData instance, which is passed to the SwiftUI view which receives changes to the object as it's an #ObservedObject.
This now works, so you can click on the original button to present the SwiftUI view. The button in that view then toggles passedData.isShowing which changes the background color. Since this is a class instance, the ViewController also has access to this value. As an example, isShowing is also toggled within tapped() after 5 seconds to show the value can be changed from ViewController or BindingProblemView.
Although it is no longer needed, the onChange(of:perform:) still triggers.
Code:
class PassedData: ObservableObject {
#Published var isShowing = true
}
class ViewController: UIViewController {
let button = UIButton(type: .system)
let passedData = PassedData()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
button.setTitle("TAP THIS BUTTON", for: .normal)
view.addSubview(button)
button.translatesAutoresizingMaskIntoConstraints = false
button.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
button.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
button.addTarget(self, action: #selector(tapped), for: .touchUpInside)
}
#objc private func tapped() {
let newView = BindingProblemView(passedData: passedData)
let vc = UIHostingController(rootView: newView)
vc.modalPresentationStyle = .overCurrentContext
present(vc, animated: false)
// Example of toggling from in view controller
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
self.passedData.isShowing.toggle()
}
}
}
struct BindingProblemView: View {
#ObservedObject var passedData: PassedData
var body: some View {
ZStack {
if passedData.isShowing {
Color.red.ignoresSafeArea().padding(0)
} else {
Color.green.ignoresSafeArea().padding(0)
}
Button("Test Binding is \(passedData.isShowing ? "ON" : "OFF")") {
passedData.isShowing.toggle()
}
}
}
}
Result:

Passing a value to a previous view controller doesn't reflect the change

I have MainViewController and DetailViewController that are stacked together by a navigation controller. I want to pass a value from DetailViewController back to the previous controller, which is MainViewController.
First, I tried it with UINavigationControllerDelegate:
class DetailViewController: UINavigationControllerDelegate {
override func viewDidLoad() {
super.viewDidLoad()
navigationController?.delegate = self
}
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
(viewController as? MainViewController)?.myClass = myClass
}
}
which was to be called as DetailViewController is popped:
_ = navigationController?.popViewController(animated: true)
But, the new value doesn't get reflected on MainViewController:
class MainViewController: UIViewController {
var myClass: MyClass
private lazy var commentLabel: UILabel = {
let comment = UILabel()
comment.text = myClass.comment
comment.numberOfLines = 0
return comment
}()
}
even though when I log myClass in MainViewController, I can see that it's being passed properly.
I also tried it with a property observer so that DetailViewController can pass it to a temporary property observer instead:
var temp: MyClass? {
willSet(newValue) {
myClass = newValue
}
}
but, the view controller's interface still doesn't change.
Finally, I tried creating a delegate in MainViewController:
protocol CallBackDelegate {
func callBack(value: MyClass)
}
where the function simply passes the argument:
func callBack(value: MyClass) {
myClass = value
}
I set the delegate to self:
if let vc = self.storyboard?.instantiateViewController(withIdentifier: "Detail") as? DetailViewController {
vc.delegate = self
self.navigationController?.pushViewController(vc, animated: true)
}
and invoking the function in DetailViewController:
delegate?.callBack(value: MyClass)
but, still doesn't update the interface. It seems as though passing the value isn't the issue, but having it be reflected is.
This is an example of using the protocol / delegate pattern. It's about as basic as it gets...
Start a new single-view project
add the code below
Set the class of the default view controller to MainViewController
embed it in a Navigation Controller
run the app
Then:
Tap the button labeled "Push to next VC"
Enter some text in the "Edit Me" field
Tap the "Pop back to previous VC"
See that the label has been updated with your entered text.
protocol CallBackDelegate: class {
func callback(_ val: String)
}
class MainViewController: UIViewController, CallBackDelegate {
let btn = UIButton()
let theLabel = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
btn.translatesAutoresizingMaskIntoConstraints = false
theLabel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(btn)
view.addSubview(theLabel)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
btn.topAnchor.constraint(equalTo: g.topAnchor, constant: 100.0),
btn.centerXAnchor.constraint(equalTo: g.centerXAnchor),
theLabel.topAnchor.constraint(equalTo: btn.bottomAnchor, constant: 20.0),
theLabel.centerXAnchor.constraint(equalTo: g.centerXAnchor),
])
theLabel.backgroundColor = .yellow
btn.backgroundColor = .red
theLabel.text = "Default text"
btn.setTitle("Push to next VC", for: [])
btn.addTarget(self, action: #selector(self.pushButtonTapped(_:)), for: .touchUpInside)
}
#objc func pushButtonTapped(_ sender: Any?) -> Void {
let vc = DetailViewController()
vc.delegate = self
self.navigationController?.pushViewController(vc, animated: true)
}
func callback(_ val: String) {
theLabel.text = val
}
}
class DetailViewController: UIViewController {
weak var delegate: CallBackDelegate?
let textField = UITextField()
let btn = UIButton()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
btn.translatesAutoresizingMaskIntoConstraints = false
textField.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(btn)
view.addSubview(textField)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
textField.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
textField.centerXAnchor.constraint(equalTo: g.centerXAnchor),
textField.widthAnchor.constraint(equalToConstant: 200.0),
btn.topAnchor.constraint(equalTo: textField.bottomAnchor, constant: 20.0),
btn.centerXAnchor.constraint(equalTo: g.centerXAnchor),
])
textField.backgroundColor = .yellow
textField.borderStyle = .roundedRect
btn.backgroundColor = .blue
textField.placeholder = "Edit me"
btn.setTitle("Pop back to previous VC", for: [])
btn.addTarget(self, action: #selector(self.popButtonTapped(_:)), for: .touchUpInside)
}
#objc func popButtonTapped(_ sender: Any?) -> Void {
if let s = textField.text {
delegate?.callback(s)
}
self.navigationController?.popViewController(animated: true)
}
}
Doesn't seem that you are updating the UILabel value anyhow
var myClass: MyClass? {
didSet {
self.commentLabel.text = myClass?.comment
}
}
You have to update the label text itself, right now it's constant with the first load data

Dismiss button in SwiftUI modal called from UIKit

I have got a SwiftUI modal view which I am calling from main UIKit view. I want to add a dismiss button to my modal view. As I can tell, there is no #State variables in UIKit, so I am creating a separate SwiftUI view to store my #State variable but for some reason it is not working. How should I fix this?
My code inside main ViewController:
var hack = StateInUIKitHack()
hack.modalIsPresented = true
let vc = UIHostingController(rootView: MoodCardView(isPresented: hack.$modalIsPresented, entryIndex: entryIndex, note: moodEntries[entryIndex].note ?? ""))
self.present(vc, animated: true, completion: nil)
StateInUIKitHack struct:
struct stateInUIKitHack: View {
#State var modalIsPresented = false
var body: some View {
Text("Hello, World!")
}
}
Inside MoodCardView.swift I have:
#Binding var isPresented: Bool
And if I create my modal sheet from another SwiftUI View the classical way it dismisses OK, but I need to create it from the UIKit view.
Here is a demo of possible approach. Tested with Xcode 11.4 / Playground
Complete playground code:
import UIKit
import SwiftUI
import PlaygroundSupport
class ViewModel {
var closeAction: () -> Void = {}
}
struct ModalView: View {
var vm: ViewModel
var body: some View {
VStack {
Text("I'm SwfitUI")
Button("CloseMe") {
self.vm.closeAction()
}
}
}
}
class MyViewController : UIViewController {
override func loadView() {
let view = UIView()
view.backgroundColor = .white
let button = UIButton(type: .roundedRect)
button.setTitle("ShowIt", for: .normal)
button.addTarget(self, action: #selector(MyViewController.showModal(_:)), for: .touchDown)
view.addSubview(button)
button.translatesAutoresizingMaskIntoConstraints = false
button.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
button.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
self.view = view
}
#objc func showModal(_ : Any?) {
let bridge = ViewModel()
let vc = UIHostingController(rootView: ModalView(vm: bridge))
bridge.closeAction = { [weak vc] in
vc?.dismiss(animated: true)
}
self.present(vc, animated: true, completion: nil)
}
}
// Present the view controller in the Live View window
PlaygroundPage.current.liveView = MyViewController()

Resources