SwiftUI ScrollView with content frame update closure - ios

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!

Related

How to "unpack" the content of a ForEach

I am building a #resultBuilder in my app. This resultBuilder takes Views and returns them in an array. This is necessary because the view which takes the content (WrappingHStack) needs it as an array. My current source looks like this:
#resultBuilder public struct ViewArrayBuilder {
public static func buildBlock() -> [EmptyView] {
[EmptyView()]
}
public static func buildBlock<Content: View>(_ content: Content) -> [AnyView] {
[AnyView(content)]
}
}
struct WrappingHStack: View {
#usableFromInline var alignment: Alignment = .topLeading
#usableFromInline var spacing: CGFloat = 8.0
#usableFromInline var maxRows: Int = Int.max
#usableFromInline let content: [AnyView]
#State private var height: CGFloat = 0
#usableFromInline init(alignment: Alignment = .topLeading,
spacing: CGFloat = 8.0,
maxRows: Int = Int.max,
content: [AnyView]) {
self.alignment = alignment
self.spacing = spacing
self.maxRows = maxRows
self.content = content
}
#inlinable public init(alignment: Alignment = .topLeading,
spacing: CGFloat = 8.0,
maxRows: Int = Int.max,
#ViewArrayBuilder content: () -> [AnyView]) {
self.init(alignment: alignment, spacing: spacing, maxRows: maxRows, content: content())
}
}
This all works fine if used like so:
WrappingHStack(maxRows: 2) {
Text("1")
Text("2")
...
}
If used with a ForEach in the closure it is recognised as one view and an array with the single ForEach is returned. But I want to get the contents of the ForEach and put them in the array. I was thinking about checking the type of the content and if a ForEach is detected it would be "unpacked".
public static func buildBlock<Content: View>(_ content: Content) -> [AnyView] {
// Doesn’t work because of this error message:
// Protocol 'RandomAccessCollection' as a type cannot conform to the protocol itself
if let forEachContent = content as? ForEach<RandomAccessCollection, Any, Any> {
return content.data.map({ elem in AnyView(content.content(elem)) })
}
return [AnyView(content)]
}
But I can’t seem to find a way to correctly ask for the ForEach type.
How would this be done? Or are there better ways to "unpack" the content of the ForEach?
Update
Why I need that?
I try to create a "wrapping" HStack. That is a view that lays out its children horizontally like a normal HStack. Once the available width is used up it wraps the children and continues on the next line. I base my approach on this article.
So at one point in time I do need the views created with the ForEach construct to lay them out as I want to. If I were able to correctly cast the content parameter of the buildBlock method to the ForEach type I could use the content function of ForEach to create the views. Like shown in the code block above.
I’m also open for other suggestions which accomplish the wrapping stack I need. (The lazy grids Apple provides are not what I want. My child views are of different width and I want them to flow like text would within the WrappingHStack.)
WrappingHStack library does the wrapping and can be used as a forEach:
WrappingHStack(1...30, id:\.self) {
Text("Item: \($0)")
}

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

Why Does #EnvironmentObject Force Re-initialization of View?

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.

Make an invisible SwiftUI to UIKit to SwiftUI bridge

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!

Resources