So there seems to be a retain cycle when injecting a Binding that is a published property from an ObservableObject into UIViewControllerRepresentable.
It seems if you create a view inside another view in and that second view has an ObservableObject and injects it's published property into the UIViewControllerRepresentable and is then used in the coordinator, the ObservableObject is never released when the original view is refreshed.
Also it looks like the Binding gets completely broken and the UIViewControllerRepresentable no longer works
When looking at it, it makes sense that Coordinator(self) is bad, but Apple's own documentation says to do it this way. Am I missing something?
Here is a quick example:
struct ContentView: View {
#State var resetView: Bool = true
var body: some View {
VStack {
OtherView()
Text("\(resetView ? 1 : 0)")
// This button just changes the state to refresh the view
// Also after this is pressed the UIViewControllerRepresentable no longer works
Button(action: {resetView.toggle()}, label: {
Text("Refresh")
})
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct OtherView: View {
#ObservedObject var viewModel = OtherViewModel()
var body: some View {
VStack {
Text("Value: \(viewModel.value)")
Wrapper(value: $viewModel.value).frame(width: 100, height: 50, alignment: .center)
}
}
}
class OtherViewModel: ObservableObject {
#Published var value: Int
deinit {
print("OtherViewModel deinit") // this never gets called
}
init() {
self.value = 0
}
}
struct Wrapper: UIViewControllerRepresentable {
#Binding var value: Int
class Coordinator: NSObject, ViewControllerDelegate {
var parent: Wrapper
init(_ parent: Wrapper) {
self.parent = parent
}
func buttonTapped() {
// After the original view is refreshed this will no longer work
parent.value += 1
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: Context) -> ViewController {
let vc = ViewController()
vc.delegate = context.coordinator
return vc
}
func updateUIViewController(_ uiViewController: ViewController, context: Context) {}
}
protocol ViewControllerDelegate: class {
func buttonTapped()
}
class ViewController: UIViewController {
weak var delegate: ViewControllerDelegate?
override func viewDidLoad() {
super.viewDidLoad()
let button = UIButton(frame: CGRect(x: 0, y: 0, width: 100, height: 20))
button.setTitle("increment", for: .normal)
button.setTitleColor(UIColor.blue, for: .normal)
button.addTarget(self, action: #selector(self.buttonTapped), for: .touchUpInside)
self.view.addSubview(button)
}
#objc func buttonTapped(sender : UIButton) {
delegate?.buttonTapped()
}
}
Related
I am a SwiftUI newbie struggling to add SwiftUI functionality to my existing UIKit/Storyboard code. I would like to call a UIKit function from SwiftUI button. Greatly appreciate your help. Here is the relevant code simplified for this discussion.
From the code below, I'd like to call the functions startAction() and stopAction() from the If statement in SwiftUI...
if (startStop_flag) {
//******** call StartAction()
} else {
//******* call StopAction()
}
The entire code below.
Some context: when the app is run, the bottom half of the screen will show "UIkit Storyboard View" and show the button "Open Swift Container View". When the user clicks this button, the SwiftUI container view will open up. This view will display "This is a swiftUI view" and display a button "Start/Stop". When the user clicks this button, StartAction() or StopAction() needs to be called. These two functions reside in the UIViewController. I hope I am clear with the problem and the request.
ViewController.swift
class ViewController: UIViewController {
#IBOutlet weak var nativeView: UIView!
#IBOutlet weak var nativeView_Label: UILabel!
#IBOutlet weak var nativeView_openSwiftViewBtn: UIButton!
#IBOutlet weak var containerView_forSwiftUI: UIView!
#IBSegueAction func embedSwiftUIView(_ coder: NSCoder) -> UIViewController? {
return UIHostingController(coder: coder, rootView: SwiftUIView2(text: "Container View"))
}
var toggleOpenCloseContainerView : Bool = false
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
containerView_forSwiftUI.isHidden = true
}
#IBAction func openContainerView_touchInside(_ sender: Any) {
if (toggleOpenCloseContainerView) {
containerView_forSwiftUI.isHidden = false
toggleOpenCloseContainerView = false
nativeView_openSwiftViewBtn.setTitle("Close Swift Container View", for: .normal)
} else {
containerView_forSwiftUI.isHidden = true
toggleOpenCloseContainerView = true
nativeView_openSwiftViewBtn.setTitle("Open Swift Container View", for: .normal)
}
}
// These two functions need to be called from the SwiftUI's button.
func startAction() {
print("Start Action called from SwiftUI's button")
}
func stopAction() {
print("Stop Action called from SwiftUI's button")
}
}
The swiftUI functions are in this file
struct SwiftUIView2: View {
var text: String
#State var startStop_flag: Bool = true
var body: some View {
VStack {
Text(text)
HStack {
Image(systemName: "smiley")
Text("This is a SwiftUI View")
Spacer()
Button("\(startStop_flag ? "Start": "Stop")") {
startStop_flag = !startStop_flag
if (startStop_flag) {
//******** call StartAction()
} else {
//******* call StopAction()
}
} .padding()
.background(Color.red)
.cornerRadius(40)
.foregroundColor(.white)
.padding(5)
.overlay(
RoundedRectangle(cornerRadius: 40)
.stroke(Color.red, lineWidth: 1)
)
}
}
.font(.largeTitle)
.background(Color.blue)
}
}
struct SwiftUIView_Previews: PreviewProvider {
static var previews: some View {
SwiftUIView2(text: "Sample Text")
}
}
You can use closures for this. First, define and call them inside your SwiftUI view.
struct SwiftUIView2: View {
var text: String
var startAction: (() -> Void) /// define closure 1
var stopAction: (() -> Void) /// define closure 2
...
...
Button("\(startStop_flag ? "Start": "Stop")") {
startStop_flag = !startStop_flag
if (startStop_flag) {
//******** call StartAction()
startAction()
} else {
//******* call StopAction()
stopAction()
}
}
}
Then, just assign the closure's contents inside ViewController.swift.
#IBSegueAction func embedSwiftUIView(_ coder: NSCoder) -> UIViewController? {
return UIHostingController(
coder: coder,
rootView: SwiftUIView2(
text: "Container View",
startAction: { [weak self] in
self?.startAction()
},
stopAction: { [weak self] in
self?.stopAction()
}
)
)
}
Here is the easiest way to open UIkit ViewController from SwiftUI Button Press.
Button("Next Page") {
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let rootViewController = storyboard.instantiateViewController(withIdentifier: "ProfileVC")
if let window = UIApplication.shared.windows.first {
window.rootViewController!.present(rootViewController, animated: true)
}
}
}
I have a UIViewController working fine in SwiftUI with the below code.
import Foundation
import SwiftUI
final class RTCVideoViewController: UIViewController {
var previewView: RTCEAGLVideoView!
var videoTrack: RTCVideoTrack!
override func viewDidLoad() {
previewView = RTCEAGLVideoView(frame: CGRect(x:0, y:0, width: UIScreen.main.bounds.size.width, height: UIScreen.main.bounds.size.height))
previewView.contentMode = UIView.ContentMode.scaleAspectFit
view.addSubview(previewView)
do {
videoTrack = try MediaCapturer.shared.createVideoTrack(videoView: previewView)
} catch {
print(error)
}
}
}
extension RTCVideoViewController : UIViewControllerRepresentable{
public typealias UIViewControllerType = RTCVideoViewController
public func makeUIViewController(context: UIViewControllerRepresentableContext<RTCVideoViewController>) -> RTCVideoViewController {
return RTCVideoViewController()
}
public func updateUIViewController(_ uiViewController: RTCVideoViewController, context: UIViewControllerRepresentableContext<RTCVideoViewController>) {
}
}
In SwiftUI view.
import Combine
import SwiftUI
struct LiveView: View {
#ObservedObject var viewModel: LiveViewModel
init(viewModel: LiveViewModel) {
self.viewModel = viewModel
}
var body: some View {
return ZStack {
RTCVideoViewController()
.edgesIgnoringSafeArea(.top)
}
}
}
So far everything works fine but I need the RTCVideoTrack when created to pass it to the LiveViewModel.
Any ideas what pattern I could implement to get notified when viewDidLoad finished or RTCVideoTrack passed to the LiveViewModel?
I changed a bit the code, it was close but simplified more.
import Foundation
import SwiftUI
struct RTCVideoViewController : UIViewControllerRepresentable {
var viewModel: LiveViewModel
func makeUIViewController(context: Context) -> UIViewController {
UIViewController()
}
func updateUIViewController(_ viewController: UIViewController, context: Context) {
print("HostBroadcastViewController:updateUIViewController")
let previewView = RTCEAGLVideoView(frame: CGRect(x:0, y:0, width: UIScreen.main.bounds.size.width, height: UIScreen.main.bounds.size.height))
previewView.contentMode = UIView.ContentMode.scaleAspectFit
viewController.view.addSubview(previewView)
do {
let videoTrack = try MediaCapturer.shared.createVideoTrack(videoView: previewView)
self.viewModel.videoTrack = videoTrack
}
catch {
print("ERROR getting RTCVideoTrack")
}
}
}
And in SwiftUI init the RTCVideoViewController with RTCVideoViewController(viewModel: self.viewModel) where ViewModel passed in view from parent.
I'm trying to observe/get some of SwiftUI's List scrolling attributes by wrapping/injection of UIScrollView using UIViewRepresentable.
I'm getting inconsistent behavior with bindings.
Pressing the buttons and changing values depending on if the button is in the parent vs child view has different results.
The bindings from my ObservableObject ScrollInfo class and the UIViewRepresentable start fine, but then break, unless the whole screen is refreshed and makeUIView runs again (like changing to a different tab).
Is there a way to force the UIViewRepresentable to run makeUIView again on a binding update? Or something that will fix this?
I'd like for isScrolling values to be updated and working all the time.
I set up a test to change the colors of the circles to red if the user is dragging the scrollview down. It works initially but stops if I update a value from the ObservableObject in the parent view.
Screenshots of Test from code below
Bindings keep working with bottom button press (updating ObservableObject) in child view
Bindings break with top button press (updating ObservableObject) in parent view
// Parent View
import SwiftUI
struct ContentView: View {
#ObservedObject var scrollInfo:ScrollInfo = ScrollInfo()
var body: some View {
VStack
{
Button(action:{
self.scrollInfo.contentLoaded = true;
})
{
Text("REFRESH")
}
TestView()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
// Child View
import SwiftUI
struct TestView: View {
#State var test1:String = "Test1"
#ObservedObject var scrollInfo:ScrollInfo = ScrollInfo()
var body: some View {
VStack
{
Button(action:{
self.scrollInfo.contentLoaded.toggle()
}) {
Text("REFRESH")
}
List{
VStack {
VStack{
Text(String( self.scrollInfo.contentLoaded))
Text(self.test1)
Circle().frame(width:50,height:50).foregroundColor(self.scrollInfo.isScrolling ? .red : .green)
}
VStack{
Text(self.scrollInfo.text)
Text(self.test1)
Circle().frame(width:50,height:50).foregroundColor(self.scrollInfo.isScrolling ? .red : .green)
}
VStack{
Text(self.scrollInfo.text)
Text(self.test1)
Circle().frame(width:50,height:50).foregroundColor(self.scrollInfo.isScrolling ? .red : .green)
}
VStack{
Text(self.scrollInfo.text)
Text(self.test1)
Circle().frame(width:50,height:50).foregroundColor(self.scrollInfo.isScrolling ? .red : .green)
}
} .padding(.bottom,620).padding(.top,20)
.background(
ListScrollingHelper(scrollInfo: self.scrollInfo)// injection
)
}.padding(.top,4).onAppear(perform:{
})
}
}
}
struct TestView_Previews: PreviewProvider {
static var previews: some View {
TestView()
}
}
// ScrollInfo Class
class ScrollInfo: ObservableObject {
#Published var isScrolling:Bool = false
#Published var text:String = "Blank"
#Published var contentLoaded:Bool = false
init()
{
print( "scrollInfo init")
}
deinit {
print("scrollInfo denit")
}
}
// UIViewRepresentable
final class ListScrollingHelper: UIViewRepresentable {
var scrollInfo:ScrollInfo
#Published var scrollView: UIScrollView?
init( scrollInfo:ScrollInfo) {
print("init UIViewRepresentable listscrolling helper")
self.scrollInfo = scrollInfo
}
func makeUIView(context: Context) -> UIView {
//self.uiScrollView.delegate = context.coordinator
print("makeview")
return UIView()
//return self.uiScrollView // managed by SwiftUI, no overloads
}
func catchScrollView(for view: UIView) {
print("checking for scrollview")
if nil == scrollView {
scrollView = view.enclosingScrollView()
if(scrollView != nil)
{
print("scrollview found")
}
}
}
func updateUIView(_ uiView: UIView, context: Context) {
catchScrollView(for: uiView)
if(scrollView != nil)
{
scrollView!.delegate = context.coordinator
}
print("updatingUIView")
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject,UIScrollViewDelegate {
var parent: ListScrollingHelper
init(_ listScrollingHelper: ListScrollingHelper) {
self.parent = listScrollingHelper
print("init coordinator")
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
// DispatchQueue.main.async {
if(!self.parent.scrollInfo.isScrolling)
{
self.parent.scrollInfo.isScrolling = true
//self.parent.scrollInfo.text = "scroll"
// }
}
print("start scroll")
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if(self.parent.scrollInfo.isScrolling && !decelerate)
{
self.parent.scrollInfo.isScrolling = false
}
print("end scroll")
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if(self.parent.scrollInfo.isScrolling)
{
self.parent.scrollInfo.isScrolling = false
}
print("end scroll")
}
deinit
{
print("deinit coordinator")
}
}
deinit {
print("deinit UIViewRepresentable listscrolling helper")
}
}
extension UIView {
func enclosingScrollView() -> UIScrollView? {
var next: UIView? = self
repeat {
next = next?.superview
if let scrollview = next as? UIScrollView {
return scrollview
}
} while next != nil
return nil
}
}
EDIT - WORKAROUND
I wasn't able to get it working with the ObservableObject or EnvironmentObject, but I was able to get it working with #State and #Binding, although it's a limited amount of info passed back. (ScrollInfo is still there only to use to testing changing a parent ObservableObject)
Hope this helps someone else!
import SwiftUI
struct TestView: View {
#State var isScrolling:Bool = false;
var body: some View {
VStack
{
Button(action:{
self.scrollInfo.contentLoaded.toggle()
}) {
Text("REFRESH")
}
List{
VStack {
VStack{
Text("isScrolling")
Text(String(self.isScrolling))
Circle().frame(width:50,height:50).foregroundColor(self.isScrolling ? .red : .green)
}
VStack{
Text(self.scrollInfo.text)
Text(self.test1)
Circle().frame(width:50,height:50).foregroundColor(self.isScrolling ? .red : .green)
}
VStack{
Text(self.scrollInfo.text)
Text(self.test1)
Circle().frame(width:50,height:50).foregroundColor(self.isScrolling ? .red : .green)
}
VStack{
Text(self.scrollInfo.text)
Text(self.test1)
Circle().frame(width:50,height:50).foregroundColor(self.isScrolling ? .red : .green)
}
} .padding(.bottom,620).padding(.top,20).background( ListScrollingHelper(isScrolling: self.$isScrolling))
}.padding(.top,4)
}
}
}
extension UIView {
func enclosingScrollView() -> UIScrollView? {
var next: UIView? = self
repeat {
next = next?.superview
if let scrollview = next as? UIScrollView {
return scrollview
}
} while next != nil
return nil
}
}
struct TestView_Previews: PreviewProvider {
static var previews: some View {
TestView()
}
}
final class ListScrollingHelper: UIViewRepresentable {
#Binding var isScrolling:Bool
private var scrollView: UIScrollView?
init(isScrolling:Binding<Bool>)
{
self._isScrolling = isScrolling
}
func makeUIView(context: Context) -> UIView {
return UIView()
}
func catchScrollView(for view: UIView) {
if nil == scrollView {
scrollView = view.enclosingScrollView()
}
}
func updateUIView(_ uiView: UIView, context: Context) {
catchScrollView(for: uiView)
if(scrollView != nil)
{
scrollView!.delegate = context.coordinator
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject,UIScrollViewDelegate {
var parent: ListScrollingHelper
init(_ listScrollingHelper: ListScrollingHelper) {
self.parent = listScrollingHelper
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
if(!self.parent.isScrolling)
{
self.parent.isScrolling = true
}
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if(self.parent.isScrolling && !decelerate)
{
self.parent.isScrolling = false
}
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if(self.parent.isScrolling)
{
self.parent.isScrolling = false
}
}
deinit
{
}
}
deinit {
}
}
class ScrollInfo: ObservableObject {
#Published var isScrolling:Bool = false
#Published var text:String = "Blank"
#Published var contentLoaded:Bool = false
}
I've been experimenting with SwiftUI and UIKit, trying to understand how data is shared between the two frameworks, and I've created a simple example for a larger project I am working on. The example is a single SwiftUI view that contains a UIViewControllerRepresentatable wrapping a custom view controller. I am trying to have the SwiftUI view display the value of one of the view controller's properties, but it does not refresh correctly when the value is changed.
struct ContentView: View {
#State var viewController = MyViewControllerRepresentable()
var body: some View {
VStack {
viewController
Text("super special property: \(viewController.viewController.data)")
}
}
}
class MyViewController: UIViewController, ObservableObject {
#Published var data = 3
override func viewDidLoad() {
let button = UIButton(type: .system)
button.setTitle("Increase by 1", for: .normal)
button.addTarget(self, action: #selector(buttonPressed), for: .touchUpInside)
view = button
}
#objc func buttonPressed() {
data += 1
}
}
struct MyViewControllerRepresentable: UIViewControllerRepresentable {
#ObservedObject var viewController = MyViewController()
func makeUIViewController(context: Context) -> UIViewController {
return self.viewController
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}
When I run the app and press the button, I can see that the actual value of data is changing, and the publisher in MyViewController is firing, but the value displayed on screen is not refreshed to reflect this.
Please note, I am very new to iOS development, and this is probably an unconventional data model. However, I don't see why it shouldn't work correctly. Suggestions for a better way to share data would be much appreciated, but I would primarily like to know if it is possible to get this working with its current data structure.
Thank you in advance.
You could create a #Binding. This means that when the value is updated for data, the views are recreated to reflect the changes.
Here is how it can be done:
struct ContentView: View {
#State private var data = 3
var body: some View {
VStack {
MyViewControllerRepresentable(data: $data)
Text("super special property: \(data)")
}
}
}
class MyViewController: UIViewController {
#Binding private var data: Int
init(data: Binding<Int>) {
self._data = data
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
let button = UIButton(type: .system)
button.setTitle("Increase by 1", for: .normal)
button.addTarget(self, action: #selector(buttonPressed), for: .touchUpInside)
view = button
}
#objc func buttonPressed() {
data += 1
}
}
struct MyViewControllerRepresentable: UIViewControllerRepresentable {
#Binding var data: Int
private let viewController: MyViewController
init(data: Binding<Int>) {
self._data = data
viewController = MyViewController(data: data)
}
func makeUIViewController(context: Context) -> UIViewController {
viewController
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}
I'm trying to call a local ViewController function from ContentView. The function uses some local variables and cannot be moved outside the ViewController.
class ViewController: UIViewController {
func doSomething() {...}
}
extension ViewController : LinkViewDelegate {...}
located on a different file:
struct ContentView: View {
init() {
viewController = .init(nibName:nil, bundle:nil)
}
var viewController: viewController
var body: some View {
Button(action: {self.viewController.doSomething()}) {
Text("Link Account")
}
}
}
UIViewController cannot be changed to something like UIViewRepresentable because LinkViewDelegate can only extend UIViewController.
So you need to create a simple bool binding in SwiftUI, flip it to true to trigger the function call in the UIKit viewController, and then set it back to false until the next time the swiftUI button is pressed. (As for LinkViewDelegate preventing something like UIViewControllerRepresentable that shouldn't stop you, use a Coordinator to handle the delegate calls.)
struct ContentView: View {
#State var willCallFunc = false
var body: some View {
ViewControllerView(isCallingFunc: $willCallFunc)
Button("buttonTitle") {
self.willCallFunc = true
}
}
}
struct ViewControllerView: UIViewControllerRepresentable {
#Binding var isCallingFunc: Bool
func makeUIViewController(context: Context) -> YourViewController {
makeViewController(context: context) //instantiate vc etc.
}
func updateUIViewController(_ uiViewController: YourViewController, context: Context) {
if isCallingFunc {
uiViewController.doSomething()
isCallingFunc = false
}
}
}
Here is a way that I've come up with which doesn't result in the "Modifying state during view update, this will cause undefined behavior" problem. The trick is to pass a reference of your ViewModel into the ViewController itself and then reset the boolean that calls your function there, not in your UIViewControllerRepresentable.
public class MyViewModel: ObservableObject {
#Published public var doSomething: Bool = false
}
struct ContentView: View {
#StateObject var viewModel = MyViewModel()
var body: some View {
MyView(viewModel: viewModel)
Button("Do Something") {
viewModel.doSomething = true
}
}
}
struct MyView: UIViewControllerRepresentable {
#ObservedObject var viewModel: MyViewModel
func makeUIViewController(context: Context) -> MyViewController {
return MyViewController(viewModel)
}
func updateUIViewController(_ viewController: MyViewController, context: Context) {
if viewModel.doSomething {
viewController.doSomething()
// boolean will be reset in viewController
}
}
}
class MyViewController: UIViewController {
var viewModel: MyViewModel
public init(_ viewModel: MyViewModel) {
self.viewModel = viewModel
}
public func doSomething() {
// do something, then reset the flag
viewModel.doSomething = false
}
}
You could pass the instance of ViewController as a parameter to ContentView:
struct ContentView: View {
var viewController: ViewController // first v lowercase, second one Uppercase
var body: some View {
Button(action: { viewController.doSomething() }) { // Lowercase viewController
Text("Link Account")
}
}
init() {
self.viewController = .init(nibName:nil, bundle:nil) // Lowercase viewController
}
}
// Use it for the UIHostingController in SceneDelegate.swift
window.rootViewController = UIHostingController(rootView: ContentView()) // Uppercase ContentView
Updated answer to better fit the question.