My app is leaking model objects because it the objects are keeping closures that are retaining the view itself.
It's better to show by an example.
In the code below, Model is not deallocated after the ContentView disappears.
//
// Content View is an owner of `Model`
// It passes it to `ViewB`
//
// When button is tapped, ContentView executes action
// assigned to Model by the ViewB
//
struct ContentView: View {
#StateObject private var model = Model()
var body: some View {
VStack {
Button(action: {
model.action?()
}) {
Text("Tap")
}
ViewB(model: model)
}
.frame(width: 100, height: 100)
.onDisappear {
print("CONTENT DISAPPEAR")
}
}
}
struct ViewB: View {
#ObservedObject var model: Model
var body: some View {
Color.red.frame(width: 20, height: 20)
.onAppear {
//
// DANGER:
// Assigning this makes a leak and Model is never deallocated.
// This is because the closure is retaining 'self'
// But since it's a struct, how can we break the cycle here?
//
model.action = { bAction() }
}
}
private func bAction() {
print("Hey!")
}
}
class Model: ObservableObject {
var action: (() -> Void)?
deinit {
print("MODEL DEINIT")
}
}
I'm not sure why there's some kind of retain cycle occurring here.
Since View is a struct, referencing it in a closure should be safe, right?
Ahoy #msmialko, while I can't give much reasoning for what I've observed, hopefully this will be a step in the right direction.
I decided to remove SwiftUI's memory management from the equation and tested with simple value and reference types:
private func doMemoryTest() {
struct ContentView {
let model: Model
func pressButton() {
model.action?()
}
}
struct ViewB {
let model: Model
func onAppear() {
model.action = action
// { [weak model] in
// model?.action = action
// }()
}
func onDisappear() {
print("on ViewB's disappear")
model.action = nil
}
private func action() {
print("Hey!")
}
}
class Model {
var action: (() -> Void)?
deinit {
print("*** DEALLOCATING MODEL")
}
}
var contentView: ContentView? = .init(model: Model())
var viewB: ViewB? = .init(model: contentView!.model)
contentView?.pressButton()
viewB?.onAppear()
contentView?.pressButton()
// viewB?.onDisappear()
print("Will remove ViewB's reference")
viewB = nil
print("Removed ViewB's reference")
contentView?.pressButton()
print("Will remove ContentView's reference")
contentView = nil
print("Removed ContentView's reference")
}
When I ran the code above, this was the console output (no deallocation of Model, as you observed):
Hey!
Will remove ViewB's reference
Removed ViewB's reference
Hey!
Will remove ContentView's reference
Removed ContentView's reference
In the above example it looks like I'm in complete control of the reference count on Model, however when I inspected the memory graph in Xcode, I could confirm that Model was retaining itself via action.context (I'm not sure what that means):
To fix the retain cycle with minimal changes, you might want to consider removing Model's action assignment using ViewB.onDisappear as I've done in my example. When I uncommented viewB?.onDisappear() then I saw the following console output:
Hey!
on ViewB's disappear
Will remove ViewB's reference
Removed ViewB's reference
Will remove ContentView's reference
*** DEALLOCATING MODEL
Removed ContentView's reference
Good luck!
Model is not a struct, it is an ObservableObject which is of type AnyObject which is an Object
you should apply weak to in the capture list for .onAppear
.onAppear { [weak model] }
I think you could also just capture model incase its self that the issue is on
.onAppear { [model] }
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.
In this example, when I drag across the screen why does LabelViewRepresentable get re-initialized before every "updateUIView" call? If I make the counter a #State property instead of an #EnvironmentObject property, it only initializes once like I would expect.
import SwiftUI
class Counter: ObservableObject {
#Published var count = 0
}
struct CounterView: View {
#EnvironmentObject var counter: Counter
var body: some View {
LabelViewRepresentable(count: $counter.count)
.gesture(DragGesture().onChanged({ _ in
self.counter.count += 1
}))
}
}
struct LabelViewRepresentable: UIViewRepresentable {
#Binding var count: Int
private var view: UILabel
init(count: Binding<Int>) {
print("init")
let label = UILabel()
label.text = "0"
self.view = label
self._count = count
}
func makeUIView(context: UIViewRepresentableContext<LabelViewRepresentable>) -> UILabel {
print("makeUIView")
return view
}
func updateUIView(_ uiView: UILabel, context: UIViewRepresentableContext<LabelViewRepresentable>) {
print("updateUIView")
view.text = "\(count)"
}
}
When you look at Apple docs about EnvironmentObject you will find this:
A dynamic view property that uses a bindable object supplied by an
ancestor view to invalidate the current view whenever the bindable
object changes.
That means that each time an EnvironmentObject changes all views that are dependent on it get reinitialised and redrawn.
It works slightly different with State, in Apple docs it is described as follows:
A persistent value of a given type, through which a view reads and
monitors the value.
The view cannot get reinitialised when the State changes as the State value would get discarded. The parts that are influenced by State will get redrawn. On the other hand any children of the view that have the State value passed in as a binding will get reinitialised.
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!
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
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!