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
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.
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
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!
I have found out some features are terribly lacking on the SwiftUI side, especially on the interactions. So I am trying to add up an in-betweener "invisible" UIViewController sandwiched between two UIViews.
TL;DR: See point 1 below.
For example, to make sure a view can receive a UIDrop interaction, or to add an UIDrag interaction, or any other interaction that can actually be influenced by other softwares (such as an iPad drag from another software to mine).
So I created a little piece of fun code that actually simply wraps around a View "Content" and gets called when the view is actually instantiated. I wanted to forego the Controller part but it doesn't seem possible on the iOS part of the equation, only macOS. Boo. Anyways, I know these features will probably eventually be added to SwiftUI, so the obvious goal of that bridge is to be as transparent as possible, so I can remove it whenever the proper way gets added to SwiftUI.
It mostly works, believe it or not! But there is a nitpick and a bug, and maybe someone actually did something similar and could actually help me figure it out. And maybe my code will help someone else as a good starting point.
(SwiftUI 11.0 beta 5)
(Bug and main question) It seems the frame size isn't propagated. So the parent of my class must actually hard-code .frame(width: something, height: something) or else my view will not be of a proper size... Which makes sense, as I don't actually propagate these values nor retrieve them from the Content. I tried hugging the size, but the frame size is not sent. Where and how should I get this value transmitted across the board. The goal of my code is to make this bridge as lean, efficient and invisible as possible to SwiftUI, developer and the end user.
(Nitpick) I found the viewWillAppear, viewDidLoad and other load-time operations are not actually called. viewWillAppear, for example, isn't called at view-time. If I navigate deeper and go back, then viewWillAppear will be called, but not the first time it's shown. So I resorted in creating a temporary "initialized" variable. What am I doing wrong (if any)?
Maybe I'm also forgetting to transmit other data across the classes and structs. For example, the UIViewController actually makes the object's background white. It could be transparent, but for me it's of no importance (I guess you can add self.view.backgroundColor = .clear). If you think of improvements, I'd be very happy to know about it!
import SwiftUI
/// Creates a SwiftUI to UIKit to SwiftUI bridge, where a lambda is called on viewWillAppear
struct UIKitBridgeView<Content>: View where Content : View {
var content: () -> Content
var onViewWillAppear: (UIView)->Void
init(onViewWillAppear: #escaping (UIView)->Void, _ content: #escaping () -> Content) {
self.content = content
self.onViewWillAppear = onViewWillAppear
}
class Coordinator: NSObject {
}
/// This is the class in UIKit that can host a SwiftUI "Content" type.
class HostingController: UIHostingController<Content> {
var onViewWillAppear: ((UIView)->Void)?
var initialized: Bool = false
init(_ content: () -> Content) {
super.init(rootView: content())
self.onViewWillAppear = nil
}
init(_ content: () -> Content, _ onViewWillAppear: #escaping (UIView)->Void) {
super.init(rootView: content())
self.onViewWillAppear = onViewWillAppear
}
#objc required dynamic init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.onViewWillAppear = nil
}
func update(context: UIViewControllerRepresentableContext<ViewControllerRepresentable>) {
if !initialized {
initialized = true
if let onViewWillAppear = self.onViewWillAppear {
onViewWillAppear(self.view)
}
// self.view.setContentHuggingPriority(.defaultHigh, for: .vertical) doesn't seem to do anything...
// self.view.setContentHuggingPriority(.defaultHigh, for: .horizontal)
}
}
}
/// This is the struct in SwiftUI that can host a UIKit's ViewController
struct ViewControllerRepresentable : UIViewControllerRepresentable {
let content: () -> Content
var onViewWillAppear: ((UIView)->Void)?
init(_ content: #escaping () -> Content) {
self.content = content
self.onViewWillAppear = nil
}
init(_ content: #escaping () -> Content, _ onViewWillAppear: #escaping (UIView)->Void) {
self.content = content
self.onViewWillAppear = onViewWillAppear
}
func makeUIViewController(context: Context) -> HostingController {
self.onViewWillAppear != nil ?
HostingController(self.content, self.onViewWillAppear!) :
HostingController(self.content)
}
func updateUIViewController(_ hostingController: HostingController, context: Context) {
hostingController.update(context: context)
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
}
var body: some View {
ViewControllerRepresentable(self.content, self.onViewWillAppear)
}
}
For a point 1 incomplete example :
UIKitBridgeView(onViewWillAppear: { (view) in
if let delegate = self.appState.dragInteractionDelegate {
view.addInteraction(self.createDraggable(delegate))
}
}) {
Spacer() // Insert what you want here
}.frame(width: 50, height: 50)
And the .frame(width, height) really annoys me!