Have a UIKit Navigation Controller that is the root view controller in the hierarchy of the storyboards. One of the button items navigates to a UIHostedViewController that produces a SwiftUI View. That SwiftUI View is in and of itself a Tab View with tabs - and all of it works fine.
One of the crux of our development is displaying large data models for the user, and we could regain a bit of screen real estate if we utilize the Navigation bar of the UIKit Navigation.
A defining attribute of our individual views is the color coding of the header and footer, we have been able to change these colors easily as the SwiftUI Tab View just calls different views with different modifiers.
What we would like to do is have the selection of the SwiftUI Tab View set a shared property with the UIKit view that would then change the navigation bar color - however the way we setup a bound variable and reload the NavigationBar setup with a didSet on that variable does not seem to be working.
The code is almost exactly as follows, less a couple of views.
class SomeViewController: UIHostingController< MySwiftUITabView > {
var headerColor: Binding<UIColor> = .constant(UIColor.blue) {
didSet {
DispatchQueue.main.async(qos: .userInteractive) {
self.setupUI()
}
}
}
var defaultColor: UIColor!
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder, rootView: MySwiftUITabView(headerColor: headerColor))
}
override func viewDidLoad() {
super.viewDidLoad()
defaultColor = self.navigationController?.navigationBar.backgroundColor
setupUI()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
teardownUI()
}
func setupUI() {
if let naviController = self.navigationController {
let newNavBarAppearance = UINavigationBarAppearance()
newNavBarAppearance.backgroundColor = headerColor.wrappedValue
naviController.navigationBar.scrollEdgeAppearance = newNavBarAppearance
naviController.navigationBar.compactAppearance = newNavBarAppearance
naviController.navigationBar.standardAppearance = newNavBarAppearance
}
}
func teardownUI() {
if let naviController = self.navigationController {
let oldNavBarAppearance = UINavigationBarAppearance()
oldNavBarAppearance.backgroundColor = defaultColor
naviController.navigationBar.scrollEdgeAppearance = oldNavBarAppearance
naviController.navigationBar.compactAppearance = oldNavBarAppearance
naviController.navigationBar.standardAppearance = oldNavBarAppearance
}
}
}
And the SwiftUI Tab View:
struct MySwiftUITabView: View {
#Binding var headerColor: UIColor
var body: some View {
TabView(selection: $selectedTab){
TestView()
.tag(0)
.onAppear {
headerColor = .orange
}
.tabItem {
Label("Test", image: "circle.fill")
}
TestView()
.tag(1)
.onAppear {
headerColor = .red
}
.tabItem {
Label("Test", image: "square.fill")
}
}
.accentColor(.white)
}
}
Would a callback be more appropriate for this? We wrapped the didSet function call in a DispatchGroup as a final attempt since it is updating the UI, but the was not effective still for what we are looking for.
In this example, the navigation bar remains the same color as the original set value (.blue) and never gets updated to the color set to the binding by the .onAppear of the swiftUI tab items.
Any help would be greatly appreciated as always!
Related
I've created this very simple wrapper with UIViewControllerRepresentable:
struct ViewControllerWrapperView: UIViewControllerRepresentable {
let controller: UIViewController
func makeUIViewController(context: UIViewControllerRepresentableContext<ViewControllerWrapperView>) -> UIViewController {
return controller
}
func updateUIViewController(_ uiViewController: UIViewController, context: UIViewControllerRepresentableContext<ViewControllerWrapperView>) {}
}
Using it to display ViewControllers in a SwiftUI view works fine:
struct ContentView: View {
#State private var viewSwitch: Bool = true
let blueView: ViewControllerWrapperView = {
let blueViewController = UIViewController()
blueViewController.view.backgroundColor = .blue
return ViewControllerWrapperView(controller: blueViewController)
}()
let redView: ViewControllerWrapperView = {
let redViewController = UIViewController()
redViewController.view.backgroundColor = .red
return ViewControllerWrapperView(controller: redViewController)
}()
var body: some View {
Button("Switch") { viewSwitch.toggle() }
if viewSwitch {
blueView
} else {
redView
}
}
}
But as soon as I wrap the ViewControllerWrapperViews in AnyView they stop working properly:
struct ContentView: View {
#State private var viewSwitch: Bool = true
let blueView: AnyView = {
let blueViewController = UIViewController()
blueViewController.view.backgroundColor = .blue
return AnyView(ViewControllerWrapperView(controller: blueViewController))
}()
let redView: AnyView = {
let redViewController = UIViewController()
redViewController.view.backgroundColor = .red
return AnyView(ViewControllerWrapperView(controller: redViewController))
}()
var body: some View {
Button("Switch") { viewSwitch.toggle() }
if viewSwitch {
blueView
} else {
redView
}
}
}
With AnyView the views don't switch when the button is tapped. Looking a bit deeper into it, I discovered the following:
For both scenarios when first displaying the view:
makeUIViewController is called on the ViewControllerWrapperView for the blue view.
updateUIViewController is called on the ViewControllerWrapperView for the blue view and the parameter uiViewController is the blue ViewController.
Now without AnyView when the switch button is tapped the life cycle of the UIViewControllerRepresentable is executed as supposed to:
updateUIViewController is called on the ViewControllerWrapperView for the blue view and the parameter uiViewController is the blue ViewController.
makeUIViewController is called on the ViewControllerWrapperView for the red view.
updateUIViewController is called on the ViewControllerWrapperView for the red view and the parameter uiViewController is the red ViewController.
dismantleUIViewController is called and the parameter uiViewController is the blue view.
But with AnyView when tapping the switch button, the only thing that happens is:
updateUIViewController is called on the ViewControllerWrapperView for the RED view and the parameter uiViewController is the BLUE ViewController.
Am I missing something or is this a bug in SwiftUI?
Am I missing something or is this a bug in SwiftUI?
It is not a bug, AnyView erases type differences, so rendering body SwiftUI engine sees only AnyView replaced with AnyView which are equal, so engine does not replace existed view, but just refreshes it (because state has been changed) that results in updateUIViewController call. All is as expected.
And that's why usage of AnyView should be very very careful and meaningful with clear understanding of process and consequences.
Swift noob here. I have a simple app that has a main page which is a ViewController, relevant code below:
import SwiftUI
class GameViewController: UIViewController {
...
func updateUI() {
switch (SettingsView().selectedBGColor) {
case 0: self.view.backgroundColor = .black
case 1: self.view.backgroundColor = .red
case 2: self.view.backgroundColor = .blue
default: break
}
}
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
updateUI()
...
}
}
And the second page is a Settings page invoked by a UIbutton press. This page is not a ViewController, just a View. Relevant code below:
struct SettingsView: View {
...
var bgcolors = ["Black", "Red", "Blue"]
#AppStorage("selectedBGColor") var selectedBGColor = 0
#Environment(\.dismiss) private var dismiss
var body: some View {
NavigationView {
Form {
...
Section {
Picker(selection: $selectedBGColor, label: Text("Background color")) {
ForEach(0 ..< bgcolors.count, id: \.self) {
Text(self.bgcolors[$0]).tag($0)
}
}
}
Section {
Button("Save and exit", action: {
GameViewController().updateUI()
dismiss.callAsFunction()
}).navigationBarTitle(Text("Settings"))
}
}
}
}
}
When I change the selected background color in the settings and close the settings by clicking the button, the background color of the ViewController doesn't change until I relaunch the app. How do I force it to update? I tried various things I found here like loadView(), setNeedsDisplay(), DispatchQueue, ViewWillAppear/ViewDidAppearand performSegue()but nothing worked. Help would be greatly appreciated!
I want to navigate to a custom UIView where the system edge gestures are disabled. I am using the SwiftUI life cycle with UIViewControllerRepresentable and overriding preferredScreenEdgesDeferringSystemGestures.
I have seen the solutions with SceneDelegates. Does preferredScreenEdgesDeferringSystemGestures have to act on window.rootViewController for it to work?
class MyUIViewController: UIViewController {
typealias UIViewControllerType = MyUIViewController
open override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
return [.all];
}
let labelDescription: UILabel = {
let label = UILabel()
label.text = "But it's not working."
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(labelDescription)
labelDescription.topAnchor.constraint(equalTo: view.topAnchor, constant: 20).isActive = true
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
}
}
struct UIViewControllerRepresentation : UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> some UIViewController {
let uiViewController = MyUIViewController()
return uiViewController
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {}
}
struct ContentView: View {
var body: some View {
NavigationView {
NavigationLink("To UIView with no system edge gestures.",
destination: UIViewControllerRepresentation())
.navigationTitle("Title")
}
}
}
https://developer.apple.com/documentation/uikit/uiviewcontroller/2887511-childforscreenedgesdeferringsyst
If I understand it correctly the system only asks the first UIViewController and if that vc doesn't return a child that the system should ask too then that's it.
Since you don't have access to the view controllers in SwiftUI (or even know what types of view controllers it will use) I opted to just swizzle the childForScreenEdgesDeferringSystemGestures and childForHomeIndicatorAutoHidden getters and return the view controller that manages these for me by looping over all the UIViewController.children.
Since you linked to this question from my Gist I will link back there for the solution which is specific to my Gist. https://gist.github.com/Amzd/01e1f69ecbc4c82c8586dcd292b1d30d
Is there a way to round the corners on an iOS page sheet view controller? Currently, iOS page sheets by default present like this:
But instead, I would like the corners to be like this:
iOS 15 added an API to customize the corner radius of sheets, UISheetPresentationController.preferredCornerRadius:
let myViewController = UIViewController()
myViewController.view.backgroundColor = .systemYellow
myViewController.sheetPresentationController?.preferredCornerRadius = 25
present(myViewController, animated: true, completion: nil)
In your view controller, you can change the view.layer.cornerRadius property to the value you want
override func viewDidLoad() {
super.viewDidLoad()
view.layer.cornerRadius = 10.0 // You can freely change this value
}
As an example, the following code:
override func viewDidLoad() {
super.viewDidLoad()
view.layer.cornerRadius = 25.0
view.backgroundColor = .systemPurple
}
gives me the following result:
I found a way to make it work.
In the onAppear method of your sheet, get the viewcontroller that is displaying the sheet using UIApplication.shared.activeWindows.last?.rootViewController then get the sheet viewcontroller with presentedViewController and do your things on it.
struct Example: View {
#State var showSheet = true
var body: some View {
Text("Hello, World!")
.sheet(isPresented: $showSheet, content: {
Text("hello")
.onAppear {
if let controller = UIApplication.shared.activeWindows.last?.rootViewController {
if let presentedVC = controller.presentedViewController {
presentedVC.view.backgroundColor = .red
presentedVC.view.layer.cornerRadius = 30
}
}
}
})
}
}
It might sound like a trivial task but I can't find a proper solution for this problem. Possibly I haven't internalized the "SwiftUI-ish" way of thinking yet.
I have a view with a button. When the view loads, there is a condition (already logged in?) under which the view should directly go to the next view. If the button is clicked, an API call is triggered (login) and if it was successful, the redirect to the next view should also happen.
My attempt was to have a model (ObservableObject) that holds the variable "shouldRedirectToUploadView" which is a PassThroughObject. Once the condition onAppear in the view is met or the button is clicked (and the API call is successful), the variable flips to true and tells the observer to change the view.
Flipping the "shouldRedirectToUploadView" in the model seems to work but I can't make the view re-evaluate that variable so the new view won't open.
Here is my implementation so far:
The model
import SwiftUI
import Combine
class SboSelectorModel: ObservableObject {
var didChange = PassthroughSubject<Void, Never>()
var shouldRedirectToUpdateView = false {
didSet {
didChange.send()
}
}
func fetch(_ text: String) {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.shouldRedirectToUpdateView = true
}
}
}
The view
import SwiftUI
struct SboSelectorView: View {
#State var text: String = ""
#ObservedObject var model: SboSelectorModel
var body: some View {
return ZStack {
if (model.shouldRedirectToUpdateView) {
UpdateView()
}
else {
Button(action: {
self.reactOnButtonClick()
}) {
Text("Start")
}
}
}.onAppear(perform: initialActions)
}
public func initialActions() {
self.model.shouldRedirectToUpdateView = true
}
private func reactOnButtonClick() {
self.model.fetch()
}
}
In good old UIKit I would have just used a ViewController to catch the action of button click and then put the new view on the navigation stack. How would I do it in SwiftUI?
In the above example I would expect the view to load, execute the onAppear() function which executes initialActions() to flip the model variable what would make the view react to that change and present the UploadView. Why doesn't it happen that way?
There are SO examples like Programatically navigate to new view in SwiftUI or Show a new View from Button press Swift UI or How to present a view after a request with URLSession in SwiftUI? that suggest the same procedure. However it does not seem to work for me. Am I missing something?
Thank you in advance!
Apple has introduced #Published which does all the model did change stuff.
This works for me and it looks much cleaner.
You can also use .onReceive() to perform stuff on a view when something in your model changes.
class SboSelectorModel: ObservableObject {
#Published var shouldRedirectToUpdateView = false
func fetch(_ text: String) {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.shouldRedirectToUpdateView = true
}
}
}
struct UpdateView: View {
var body: some View {
Text("Hallo")
}
}
struct SboSelectorView: View {
#State var text: String = ""
#ObservedObject var model = SboSelectorModel()
var body: some View {
ZStack {
if (self.model.shouldRedirectToUpdateView) {
UpdateView()
}
else {
Button(action: {
self.reactOnButtonClick()
}) {
Text("Start")
}
}
}.onAppear(perform: initialActions)
}
public func initialActions() {
self.model.shouldRedirectToUpdateView = true
}
private func reactOnButtonClick() {
self.model.fetch("")
}
}
I hope this helps.
EDIT
So this seems to have changed in beta 5
Here a working model with PassthroughSubject:
class SboSelectorModel: ObservableObject {
let objectWillChange = PassthroughSubject<Bool, Never>()
var shouldRedirectToUpdateView = false {
willSet {
objectWillChange.send(shouldRedirectToUpdateView)
}
}
func fetch(_ text: String) {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.shouldRedirectToUpdateView = true
}
}
}
Just in case any wants an alternative to the SwiftUI way of having Observable Objects and the like - which can be great, but as I was building out, I noticed I had like, 100 objects and didn't like in the slightest how complicated it all felt. (Oh, how I wanted to just type self.present("nextScene", animated: true)). I know a large part of this is my mind just not up to that SwiftUI life yet but just in case anyone else wants a more... UIKit meets SwiftUI alternative, here's a system that works.
I'm not a professional so I don't know if this is the best memory management way.
First, create a function that allows you to know what the top view controller is on the screen. The code below was borrowed from db0Company on GIT.
import UIKit
extension UIViewController {
func topMostViewController() -> UIViewController {
if let presented = self.presentedViewController {
return presented.topMostViewController()
}
if let navigation = self.presentedViewController as? UINavigationController {
return navigation.visibleViewController?.topMostViewController() ?? navigation
}
if let tab = self as? UITabBarController {
return tab.selectedViewController?.topMostViewController() ?? tab
}
return self
}
}
extension UIApplication {
func topMostViewController() -> UIViewController? {
return self.keyWindow?.rootViewController?.topMostViewController()
}
}
Create an enum - now this is optional, but I think very helpful - of your SwiftUI and UIViewControllers; for demonstration purposes, I have 2.
enum RootViews {
case example, welcome
}
Now, here's some fun; create a delegate you can call from your SwiftUI views to move you from scene to scene. I call mine Navigation Delegate.
I added some default presentation styles here, to make calls easier via the extension.
import UIKit //SUPER important!
protocol NavigationDelegate {
func moveTo(view: RootViews, presentation: UIModalPresentationStyle, transition: UIModalTransitionStyle)
}
extension NavigationDelegate {
func moveTo(view: RootViews) {
self.moveTo(view: view, presentation: .fullScreen, transition: .crossDissolve)
}
func moveTo(view: RootViews, presentation: UIModalPresentationStyle) {
self.moveTo(view: view, presentation: presentation, transition: .crossDissolve)
}
func moveTo(view: RootViews, transition: UIModalTransitionStyle) {
self.moveTo(view: view, presentation: .fullScreen, transition: transition)
}
}
And here, I create a RootViewController - a classic, Cocoa Touch Class UIViewController. This will conform to the delegate, and be where we actually move screens.
class RootViewController: UIViewController, NavigationDelegate {
override func viewDidLoad() {
super.viewDidLoad()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.moveTo(view: .welcome) //Which can always be changed
}
//The Moving Function
func moveTo(view: RootViews, presentation: UIModalPresentationStyle = .fullScreen, transition: UIModalTransitionStyle = .crossDissolve) {
let newScene = self.returnSwiftUIView(type: view)
newScene.modalPresentationStyle = presentation
newScene.modalTransitionStyle = transition
//Top View Controller
let top = self.topMostViewController()
top.present(newScene, animated: true)
}
//Swift View switch. Optional, but my Xcode was not happy when I tried to return a UIHostingController in line.
func returnSwiftUIView(type: RootViews) -> UIViewController {
switch type {
case .welcome:
return UIHostingController(rootView: WelcomeView(delegate: self))
case .example:
return UIHostingController(rootView: ExampleView(delegate: self))
}
}
}
So now, when you create new SwiftUI Views, you just need to add the Navigation Delegate, and call it when a button is pressed.
import SwiftUI
import UIKit //Very important! Don't forget to import UIKit
struct WelcomeView: View {
var delegate: NavigationDelegate?
var body: some View {
Button(action: {
print("full width")
self.delegate?.moveTo(view: .name)
}) {
Text("NEXT")
.frame(width: UIScreen.main.bounds.width - 20, height: 50, alignment: .center)
.background(RoundedRectangle(cornerRadius: 15, style: .circular).fill(Color(.systemPurple)))
.accentColor(.white)
}
}
And last but not least, in your scene delegate, create your RootViewController() and use that as your key, instead of the UIHostingController(rootView: contentView).
Voila.
I hope this can help someone out there! And for my more professional senior developers out there, if you can see a way to make it... cleaner? Or whatever it is that makes code less bad, feel free!