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!
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.
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
I'm looking for an equivalent of AppKit's NSHostingView for UIKit so that I can embed a SwiftUI view in UIKit. Unfortunately, UIKit does not have an equivalent class to NSHostingView. The closest we have as an equivalent of NSHostingController, named UIHostingController. Since a view controller contains a view, we should be able to call the appropriate UIViewController embedding methods, and then grab the view and use it directly.
There are many articles that explain that this is the way to embed a SwiftUI view inside UIKit. However, they typically fall short in explaining how you would communicate from UIKit ➡️ SwiftUI. For example, imagine I implemented a SwiftUI view that acts as a progress bar, periodically, I'd like the progress to be updated. I want my legacy/UIKit code to update the SwiftUI view to display the new progress.
The only article I found that came close to explaining how to manipulate an embedded view's content suggested we do so by using #ObservedObject:
import UIKit
import SwiftUI
import Combine
class CircleModel: ObservableObject {
var didChange = PassthroughSubject<Void, Never>()
var text: String { didSet { didChange.send() } }
init(text: String) {
self.text = text
}
}
struct CircleView : View {
#ObservedObject var model: CircleModel
var body: some View {
ZStack {
Circle()
.fill(Color.blue)
Text(model.text)
.foregroundColor(Color.white)
}
}
}
class ViewController: UIViewController {
private weak var timer: Timer?
private var model = CircleModel(text: "")
override func viewDidLoad() {
super.viewDidLoad()
addCircleView()
startTimer()
}
deinit {
timer?.invalidate()
}
}
private extension ViewController {
func addCircleView() {
let circleView = CircleView(model: model)
let controller = UIHostingController(rootView: circleView)
addChild(controller)
controller.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(controller.view)
controller.didMove(toParent: self)
NSLayoutConstraint.activate([
controller.view.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.5),
controller.view.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: 0.5),
controller.view.centerXAnchor.constraint(equalTo: view.centerXAnchor),
controller.view.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
func startTimer() {
var index = 0
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
index += 1
self?.model.text = "Tick \(index)"
}
}
}
This seems to make sense as the timer should trigger a chain of events that update the view:
✅ self?.model.text = "Tick 1" (In ViewController.startTimer()).
✅ didChange.send() (In CircleModel.text.didSet)
❌ Text(model.text) (In CircleView.body)
As you can see by the indicators (which specify if something was run or not), the problem is that didChange.send() never triggers a re-run of CircleView.body.
How do I communicate from UIKit > SwiftUI to manipulate a SwiftUI view that was embedded in UIKit?
All you need is to throw away that custom subject, and use standard #Published, as below
class CircleModel: ObservableObject {
#Published var text: String
init(text: String) {
self.text = text
}
}
Tested on: Xcode 11.2 / iOS 13.2
Using a List view, is there a way to access (and therefore modify) the underlying UITableView object without reimplementing the entire List as a UIViewRepresentable?
I've tried initializing a List within my own UIViewRepresentable, but I can't seem to get SwiftUI to initialize the view when I need it to, and I just get an empty basic UIView with no subviews.
This question is to help find an answer for Bottom-first scrolling in SwiftUI.
Alternatively, a library or other project that reimplements UITableView in SwiftUI would also answer this question.
The answer is Yes. There's an amazing library that lets you inspect the underlying UIKit views. Here's a link to it.
The answer is no. As of iOS 13, SwiftUI's List is not currently designed to replace all the functionality and customizability of UITableView. It is designed to meet the most basic use of a UITableView: a standard looking, scrollable, editable list where you can place a relatively simply view in each cell.
In other words, you are giving up customizability for the simplicity of having swipes, navigation, moves, deletes, etc. automatically implemented for you.
I'm sure that as SwiftUI evolves, List (or an equivalent view) will get more customizable, and we'll be able to do things like scroll from the bottom, change padding, etc. The best way to make sure this happens is to file feedback suggestions with Apple. I'm sure the SwiftUI engineers are already hard at work designing the features that will appear at WWDC 2020. The more input they have to guide what the community wants and needs, the better.
I found a library called Rotoscope on GitHub (I am not the author of this).
This library is used to implement RefreshUI also on GitHub by the same author.
How it works is that Rotoscope has a tagging method, which overlays a 0 sized UIViewRepresentable on top of your List (so it's invisible). The view will dig through the chain of views and eventually find the UIHostingView that's hosting the SwiftUI views. Then, it will return the first subview of the hosting view, which should contains a wrapper of UITableView, then you can access the table view object by getting the subview of the wrapper.
The RefreshUI library uses this library to implement a refresh control to the SwiftUI List (you can go into the GitHub link and check out the source to see how it's implemented).
However, I see this more like a hack than an actual method, so it's up to you to decide whether you want to use this or not. There are no guarantee that it will continue working between major updates as Apple could change the internal view layout and this library will break.
You can Do it. But it requires a Hack.
Add Any custom UIView
Use UIResponder to backtrack until you find table View.
Modify UITableView The way you like.
Code Example of Adding Pull to refresh:
//1: create a custom view
final class UIKitView : UIViewRepresentable {
let callback: (UITableView) -> Void
init(leafViewCB: #escaping ((UITableView) -> Void)) {
callback = leafViewCB
}
func makeUIView(context: Context) -> UIView {
let view = UIView.init(frame: CGRect(x: CGFloat.leastNormalMagnitude,
y: CGFloat.leastNormalMagnitude,
width: CGFloat.leastNormalMagnitude,
height: CGFloat.leastNormalMagnitude))
view.backgroundColor = .clear
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
if let superView = uiView.superview {
superView.backgroundColor = uiView.backgroundColor
}
if let tableView = uiView.next(UITableView.self) {
callback(tableView)
}
}
}
extension UIResponder {
func next<T: UIResponder>(_ type: T.Type) -> T? {
return next as? T ?? next?.next(type)
}
}
////Use:
struct Result: Identifiable {
var id = UUID()
var value: String
}
class RefreshableObject: ObservableObject {
let id = UUID()
#Published var items: [Result] = [Result(value: "Binding"),
Result(value: "ObservableObject"),
Result(value: "Published")]
let refreshControl: UIRefreshControl
init() {
refreshControl = UIRefreshControl()
refreshControl.addTarget(self, action:
#selector(self.handleRefreshControl),
for: .valueChanged)
}
#objc func handleRefreshControl(sender: UIRefreshControl) {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
sender.endRefreshing()
self?.items = [Result(value:"new"), Result(value:"data"), Result(value:"after"), Result(value:"refresh")]
}
}
}
struct ContentView: View {
#ObservedObject var refreshableObject = RefreshableObject()
var body: some View {
NavigationView {
Form {
Section(footer: UIKitView.init { (tableView) in
if tableView.refreshControl == nil {
tableView.refreshControl = self.refreshableObject.refreshControl
}
}){
ForEach(refreshableObject.items) { result in
Text(result.value)
}
}
}
.navigationBarTitle("Nav bar")
}
}
}
Screenshot:
To update from refresh action, binding isUpdateOrdered is being used.
this code is based on code I found in web, couldn't find the author
import Foundation
import SwiftUI
class Model: ObservableObject{
#Published var isUpdateOrdered = false{
didSet{
if isUpdateOrdered{
update()
isUpdateOrdered = false
print("we got him!")
}
}
}
var random = 0
#Published var arr = [Int]()
func update(){
isUpdateOrdered = false
//your update code.... maybe some fetch request or POST?
}
}
struct ContentView: View {
#ObservedObject var model = Model()
var body: some View {
NavigationView {
LegacyScrollViewWithRefresh(isUpdateOrdered: $model.isUpdateOrdered) {
VStack{
if model.arr.isEmpty{
//this is important to fill the
//scrollView with invisible data,
//in other case scroll won't work
//because of the constraints.
//You may get rid of them if you like.
Text("refresh!")
ForEach(1..<100){ _ in
Text("")
}
}else{
ForEach(model.arr, id:\.self){ i in
NavigationLink(destination: Text(String(i)), label: { Text("Click me") })
}
}
}
}.environmentObject(model)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct LegacyScrollViewWithRefresh: UIViewRepresentable {
enum Action {
case idle
case offset(x: CGFloat, y: CGFloat, animated: Bool)
}
typealias Context = UIViewRepresentableContext<Self>
#Binding var action: Action
#Binding var isUpdateOrdered: Bool
private let uiScrollView: UIScrollView
private var uiRefreshControl = UIRefreshControl()
init<Content: View>(isUpdateOrdered: Binding<Bool>, content: Content) {
let hosting = UIHostingController(rootView: content)
hosting.view.translatesAutoresizingMaskIntoConstraints = false
self._isUpdateOrdered = isUpdateOrdered
uiScrollView = UIScrollView()
uiScrollView.addSubview(hosting.view)
let constraints = [
hosting.view.leadingAnchor.constraint(equalTo: uiScrollView.leadingAnchor),
hosting.view.trailingAnchor.constraint(equalTo: uiScrollView.trailingAnchor),
hosting.view.topAnchor.constraint(equalTo: uiScrollView.contentLayoutGuide.topAnchor),
hosting.view.bottomAnchor.constraint(equalTo: uiScrollView.contentLayoutGuide.bottomAnchor),
hosting.view.widthAnchor.constraint(equalTo: uiScrollView.widthAnchor)
]
uiScrollView.addConstraints(constraints)
self._action = Binding.constant(Action.idle)
}
init<Content: View>(isUpdateOrdered: Binding<Bool>, #ViewBuilder content: () -> Content) {
self.init(isUpdateOrdered: isUpdateOrdered, content: content())
}
init<Content: View>(isUpdateOrdered: Binding<Bool>, action: Binding<Action>, #ViewBuilder content: () -> Content) {
self.init(isUpdateOrdered: isUpdateOrdered, content: content())
self._action = action
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UIScrollView {
uiScrollView.addSubview(uiRefreshControl)
uiRefreshControl.addTarget(context.coordinator, action: #selector(Coordinator.handleRefreshControl(arguments:)), for: .valueChanged)
return uiScrollView
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
switch self.action {
case .offset(let x, let y, let animated):
uiView.setContentOffset(CGPoint(x: x, y: y), animated: animated)
DispatchQueue.main.async {
self.action = .idle
}
default:
break
}
}
class Coordinator: NSObject {
let legacyScrollView: LegacyScrollViewWithRefresh
init(_ legacyScrollView: LegacyScrollViewWithRefresh) {
self.legacyScrollView = legacyScrollView
}
#objc func handleRefreshControl(arguments: UIRefreshControl){
print("refreshing")
self.legacyScrollView.isUpdateOrdered = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2){
arguments.endRefreshing()
//refresh animation will
//always be shown for 2 seconds,
//you may connect this behaviour
//to your update completion
}
}
}
}
There is currently no way to access or modify the underlying UITableView