Dismiss button in SwiftUI modal called from UIKit - ios

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

Related

Accessing Value of SwiftUI View through a UIKit View

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.

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:

How to fix subview not showing in child view controller when added to a parent view controller

I'm trying to add a child view controller to a parent view controller in a swift ios application, but when I add the child view controller, the activityIndicatorView doesn't appear. What could I be missing?
Here is a snippet that can be tried in a playground:
import PlaygroundSupport
import Alamofire
class LoadingViewController: UIViewController {
private lazy var activityIndicator = UIActivityIndicatorView(style: .gray)
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
activityIndicator.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(activityIndicator)
NSLayoutConstraint.activate([
activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// We use a 0.5 second delay to not show an activity indicator
// in case our data loads very quickly.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.0) { [weak self] in
self?.activityIndicator.startAnimating()
}
}
}
// methods for adding and removing child view controllers.
extension UIViewController {
func add(_ child: UIViewController, frame: CGRect? = nil) {
addChild(child)
if let frame = frame {
child.view.frame = frame
}
view.addSubview(child.view)
child.didMove(toParent: self)
}
func remove() {
// Just to be safe, we check that this view controller
// is actually added to a parent before removing it.
guard parent != nil else {
return
}
willMove(toParent: nil)
view.removeFromSuperview()
removeFromParent()
}
}
class MyViewController : UITabBarController {
override func loadView() {
let view = UIView()
view.backgroundColor = .white
let label = UILabel()
label.frame = CGRect(x: 150, y: 200, width: 200, height: 20)
label.text = "Hello World!"
label.textColor = .black
view.addSubview(label)
self.view = view
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
let loadingViewController = LoadingViewController()
add(loadingViewController, frame: view.frame)
AF.request("http://www.youtube.com").response { response in
print(String(describing: response.response))
loadingViewController.remove()
}
}
}
// Present the view controller in the Live View window
PlaygroundPage.current.liveView = MyViewController()
The loadingViewController view fills the parent view, and i've tried to change the background colour at different points and that works. but activityIndicator or any other subview i try to add just doesn't appear.
Try add the line
activityIndicator.startAnimating()
in your viewDidLoad() method from your LoadingViewController class

Resources