Make an invisible SwiftUI to UIKit to SwiftUI bridge - ios

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!

Related

SwiftUI - ObservedObject is never deallocated

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] }

How to implement a custom view modifier

To be clear, i'm not asking how to use the ViewModifier protocol to create a struct with the body function that can then be used to modify a view. This question is a little bit different.
I'm trying to create a reusable alternative to the NavigationView struct, which has been mostly successful using #Viewbuilder to enable trailing closure syntax which enables me to use the view i've named 'NavBarView' like this:
NavBarView(foregroundColor: .gray) {
Text("Child view")
}
Which uses the following initializer:
init(foregroundColor: Color, #ViewBuilder content: () -> Content) {
self.foregroundColor = foregroundColor
self.content = content()
}
I can post all the code here for the NavBarView struct if you'd like to see it, but I haven't for brevity.
This code compiles fine and creates the desired effect which looks like this:
However, I'd like to be able to implement the optional ability to add items to the 'navigation bar', similar to how you can call .navigationBarItems(trailing: ) on views inside a navigation view. I'm not sure I could go about implementing this though.
What i've tried so far is creating an optional state property in the NavBarView struct called item, where it's type Item conforms to View as follows:
#State var item: Item?
This item is then placed into an HStack so that when it isn't optional it should be showed next to the "Parent View" text.
I've then written the following function within the NavBarView struct:
func trailingItem(#ViewBuilder _ item: () -> Item) -> some View {
self.item = item()
return self
}
I've then attempted to call the function like this:
NavBarView(foregroundColor: .gray) {
Text("Child view")
}.trailingItem{Text("test test")}
However, I'm not getting any text appearing, and the debug button which i've hooked up to print out what is in the item property prints out nil, so for some reason that function isn't setting the item property as Text("test test").
Am I going about this completely the wrong way? Could someone shed any light on how I might go about achieving the desired behavior?
This is possible approach, the only small correction to your modifier
extension NavBarView {
func trailingItem(#ViewBuilder _ item: #escaping () -> Item) -> some View {
var view = self // make modifiable
view.item = item()
return view
}
}
and so you don't need to have it as #State, just declare it as
fileprivate var item: Item?
Tested with Xcode 11.4

SwiftUI: How to show next view after button click + API call

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!

Access underlying UITableView from SwiftUI List

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

SwiftUI ScrollView with content frame update closure

I want to have a ScrollView where you can be aware of the content frame changes as the user scrolls (similar to didScroll delegate in UIKit UIScrollView).
With this, you can then perform layout changes based on the scroll behavior.
I managed to come with a nice solution for this problem by making use of View Preferences as a method to notify layout information upstream in the View Hierarchy.
For a very detail explanation of how View Preferences work, I will suggest reading this 3 articles series on the topic by kontiki
For my solution, I implemented two ViewModifiers: one to make a view report changes on its layout using anchor preferences, and the second to allow a View to handle updates to frames on views on its subtree.
To do this, we first define a Struct to carry the identifiable frame information upstream:
/// Represents the `frame` of an identifiable view as an `Anchor`
struct ViewFrame: Equatable {
/// A given identifier for the View to faciliate processing
/// of frame updates
let viewId : String
/// An `Anchor` representation of the View
let frameAnchor: Anchor<CGRect>
// Conformace to Equatable is required for supporting
// view udpates via `PreferenceKey`
static func == (lhs: ViewFrame, rhs: ViewFrame) -> Bool {
// Since we can currently not compare `Anchor<CGRect>` values
// without a Geometry reader, we return here `false` so that on
// every change on bounds an update is issued.
return false
}
}
and we define a Struct conforming to PreferenceKey protocol to hold the view tree preference changes:
/// A `PreferenceKey` to provide View frame updates in a View tree
struct FramePreferenceKey: PreferenceKey {
typealias Value = [ViewFrame] // The list of view frame changes in a View tree.
static var defaultValue: [ViewFrame] = []
/// When traversing the view tree, Swift UI will use this function to collect all view frame changes.
static func reduce(value: inout [ViewFrame], nextValue: () -> [ViewFrame]) {
value.append(contentsOf: nextValue())
}
}
Now we can define the ViewModifiers I mentioned:
Make a view report changes on its layout:
This just adds a transformAnchorPreference modifier to the View with a handler that simply constructs a ViewFrame instance with current frame Anchor value and appends it to the current value of the FramePreferenceKey:
/// Adds an Anchor preference to notify of frame changes
struct ProvideFrameChanges: ViewModifier {
var viewId : String
func body(content: Content) -> some View {
content
.transformAnchorPreference(key: FramePreferenceKey.self, value: .bounds) {
$0.append(ViewFrame(viewId: self.viewId, frameAnchor: $1))
}
}
}
extension View {
/// Adds an Anchor preference to notify of frame changes
/// - Parameter viewId: A `String` identifying the View
func provideFrameChanges(viewId : String) -> some View {
ModifiedContent(content: self, modifier: ProvideFrameChanges(viewId: viewId))
}
}
Provide an update handler to a view for frame changes on its subtree:
This adds a onPreferenceChange modifier to the View, where the list of frame Anchors changes are transformed into frames(CGRect) on the view's coordinate space and reported as a dictionary of frame updates keyed by the view ids:
typealias ViewTreeFrameChanges = [String : CGRect]
/// Provides a block to handle internal View tree frame changes
/// for views using the `ProvideFrameChanges` in own coordinate space.
struct HandleViewTreeFrameChanges: ViewModifier {
/// The handler to process Frame changes on this views subtree.
/// `ViewTreeFrameChanges` is a dictionary where keys are string view ids
/// and values are the updated view frame (`CGRect`)
var handler : (ViewTreeFrameChanges)->Void
func body(content: Content) -> some View {
GeometryReader { contentGeometry in
content
.onPreferenceChange(FramePreferenceKey.self) {
self._updateViewTreeLayoutChanges($0, in: contentGeometry)
}
}
}
private func _updateViewTreeLayoutChanges(_ changes : [ViewFrame], in geometry : GeometryProxy) {
let pairs = changes.map({ ($0.viewId, geometry[$0.frameAnchor]) })
handler(Dictionary(uniqueKeysWithValues: pairs))
}
}
extension View {
/// Adds an Anchor preference to notify of frame changes
/// - Parameter viewId: A `String` identifying the View
func handleViewTreeFrameChanges(_ handler : #escaping (ViewTreeFrameChanges)->Void) -> some View {
ModifiedContent(content: self, modifier: HandleViewTreeFrameChanges(handler: handler))
}
}
LET'S USE IT:
I will illustrate the usage with an example:
Here I will get notifications of a Header View frame changes inside a ScrollView. Since this Header View is on the top of the ScrollView content, the reported frame changes on the frame origin are equivalent to the contentOffset changes of the ScrollView
enum TestEnum : String, CaseIterable, Identifiable {
case one, two, three, four, five, six, seven, eight, nine, ten
var id: String {
rawValue
}
}
struct TestView: View {
private let _listHeaderViewId = "testView_ListHeader"
var body: some View {
ScrollView {
// Header View
Text("This is some Header")
.provideFrameChanges(viewId: self._listHeaderViewId)
// List of test values
ForEach(TestEnum.allCases) {
Text($0.rawValue)
.padding(60)
}
}
.handleViewTreeFrameChanges {
self._updateViewTreeLayoutChanges($0)
}
}
private func _updateViewTreeLayoutChanges(_ changes : ViewTreeFrameChanges) {
print(changes)
}
}
There is an elegant solution to this problem, Soroush Khanlou already posted a Gist of it so I won't copy-paste it. You can find it here and yeah...Shame that it isn't a part of the framework yet!

Resources