Is there any way to create a new Gesture in SwiftUI? - ios

SwiftUI is missing a Pan gesture (that is, both scale and offset), so I was trying to create one. However, it appears that the Gesture struct depends on private classes. For example:
public struct PinchGesture: Gesture {
public struct PinchGestureValue: Equatable {
var scale: CGFloat
var anchor: UnitPoint
var offset: CGSize
var isPinching: Bool
}
public typealias Value = PinchGestureValue
public typealias Body = Never
var minimumScaleDelta: CGFloat
var minimumDistance: CGFloat
var coordinateSpace: CoordinateSpace
public init(minimumScaleDelta: CGFloat = 0.01, minimumDistance: CGFloat = 10, coordinateSpace: CoordinateSpace = .local) {
self.minimumScaleDelta = minimumScaleDelta
self.minimumDistance = minimumDistance
self.coordinateSpace = coordinateSpace
}
public static func _makeGesture(gesture: _GraphValue<PinchGesture>, inputs: _GestureInputs) -> _GestureOutputs<PinchGestureValue> {
// Unable to complete
}
}
This code cannot be completed, as the _GraphValue, _GestureInputs, and _GestureOutputs are private. Before I give in completely, I wanted to see if anyone has figured out a workaround.

SwiftUI provides a default implementation of _makeGesture:
extension Gesture where Self.Value == Self.Body.Value {
public static func _makeGesture(gesture: SwiftUI._GraphValue<Self>, inputs: SwiftUI._GestureInputs) -> SwiftUI._GestureOutputs<Self.Body.Value>
}
The difficulty here is the constraint Self.Value === Self.Body.Value. That means your gesture's body can't be declared to return some Gesture, because some Gesture can't satisfy the constraint (even if its Value would match). So you have to give body a specific type. The easiest solution is to use the AnyGesture type eraser:
public struct PinchGesture: Gesture {
...
public var body: AnyGesture<PinchGestureValue> {
AnyGesture(
DragGesture(minimumDistance: 0, coordinateSpace: .global)
.map { PinchGestureValue($0) }
)
}
}
In this code, Swift can infer PinchGesture.Value = PinchGestureValue and PinchGesture.Body = AnyGesture<PinchGestureValue>. Then it can prove AnyGesture<PinchGestureValue>.Value == PinchGesture.Value, so it can use the default implementation of _makeGesture provided by SwiftUI.
Unfortunately, I doubt you can use this to create your PinchGesture. Ultimately, your body is still restricted to combining SwiftUI's primitive gestures, which don't give you access the current UIEvent or UITouch objects.

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)")
}

What does animatableData in SwiftUI do?

I was playing around with the animatableData property for a custom Shape I made but I couldn't really visualise what it does and where the system uses it.
I didn't understand how the system knows which animatableData properties it should interpolate when there's a state change. I also didn't understand what the get part of the animatableData property is used for by the system. The only thing I sort of understand is that SwiftUI will update the animatableData property to all the intermediary values between the original and final value for when an #State variable is changed.
If someone can give a very detailed order of events for the use of animatableData by the system I'll be extremely grateful. Make it as detailed as you can because I'm one of those people who feels scratchy even if I'm not understanding 1% of something (however if I do have any question I'll just ask you in the comments).
Thanks in advance!
P.S. I tried returning a constant in the getter for animatableData and my animation still worked perfectly which has confused me even more. Please let me know what the getter is used for if you can.
The simplest answer to your question is to override the default animatableData [inherited by the Animatable protocol] with values used to draw your View. Here's an example of how to do that:
var animatableData: Double {
get { return percent }
set { percent = newValue }
}
Here's an example for you. It:
Draws a Ring on the parent View.
As the value of percent [which you hook up when you define
animatableData] changes, the animation updates the view by drawing a line along the circumference of the defined circle using the percent value at the time of the update.
import SwiftUI
/// This repeats an animation until 5 seconds elapse
struct SimpleAnswer: View {
/// the start/stop sentinel
static var shouldAnimate = true
/// the percentage of the circumference (arc) to draw
#State var percent = 0.0
/// animation duration/delay values
var animationDuration: Double { return 1.0 }
var animationDelay: Double { return 0.2 }
var exitAnimationDuration: Double { return 0.3 }
var finalAnimationDuration: Double { return 1.0 }
var minAnimationInterval: Double { return 0.1 }
var body: some View {
ZStack {
AnimatingOverlay(percent: percent)
.stroke(Color.yellow, lineWidth: 8.0)
.rotationEffect(.degrees(-90))
.aspectRatio(1, contentMode: .fit)
.padding(20)
.onAppear() {
self.performAnimations()
}
.frame(width: 150, height: 150,
alignment: .center)
Spacer()
}
.background(Color.blue)
.edgesIgnoringSafeArea(.all)
}
func performAnimations() {
run()
if SimpleAnswer.shouldAnimate {
restartAnimation()
}
/// Stop the Animation after 5 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: { SimpleAnswer.shouldAnimate = false })
}
func run() {
withAnimation(.easeIn(duration: animationDuration)) {
percent = 1
}
let deadline: DispatchTime = .now() + animationDuration + animationDelay
DispatchQueue.main.asyncAfter(deadline: deadline) {
withAnimation(.easeOut(duration: self.exitAnimationDuration)) {
}
withAnimation(.easeOut(duration: self.minAnimationInterval)) {
}
}
}
func restartAnimation() {
let deadline: DispatchTime = .now() + 2 * animationDuration + finalAnimationDuration
DispatchQueue.main.asyncAfter(deadline: deadline) {
self.percent = 0
self.performAnimations()
}
}
}
/// Draws a Ring on the parent View
/// By default, `Shape` returns the instance of `EmptyAnimatableData` struct as its animatableData.
/// All you have to do is replace this default `EmptyAnimatableData` with a different value.
/// As the value of percent changes, the animation updates the view
struct AnimatingOverlay: Shape {
var percent: Double
func path(in rect: CGRect) -> Path {
let end = percent * 360
var p = Path()
p.addArc(center: CGPoint(x: rect.size.width/2, y: rect.size.width/2),
radius: rect.size.width/2,
startAngle: Angle(degrees: 0),
endAngle: Angle(degrees: end),
clockwise: false)
return p
}
/// This example defines `percent` as the value to animate by
/// overriding the value of `animatableData`
/// inherited as Animatable.animatableData
var animatableData: Double {
get { return percent }
set { percent = newValue }
}
}
#if DEBUG
struct SimpleAnswer_Previews : PreviewProvider {
static var previews: some View {
SimpleAnswer()
}
}
#endif
I found these links to help me answer your question. You should find them useful as well.
Wenderlich - How to Create a Splash Screen With SwiftUI
Majid - The Magic of Animatable Values
Animations in SwiftUI - Majid

Detect DragGesture cancelation in SwiftUI

So I have a Rectangle with an added DragGesture and want to track gesture start, change and ending. The issue is when I put another finger on the Rectangle while performing the gesture, the first gesture stop calling onChange handler and does not fire onEnded handler.
Also the handlers doesn't fire for that second finger.
But if I place third finger without removing previous two the handlers for that gesture start to fire (and so on with even presses cancel out the odd ones)
Is it a bug? Is there a way to detect that the first gesture was canceled?
Rectangle()
.fill(Color.purple)
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onChanged() { event in
self.debugLabelText = "changed \(event)"
}
.onEnded() { event in
self.debugLabelText = "ended \(event)"
}
)
Thanks to #krjw for the hint with an even number of fingers
This appears to be a problem in the Gesture framework for attempting to detect a bunch of gestures even if we didn't specify that it should be listening for them.
As the documentation is infuriatingly sparse we can only really guess at what the intended behaviour and lifecycle here is meant to be (IMHO - this seems like a bug) - but it can be worked around.
Define a struct method like
func onDragEnded() {
// set state, process the last drag position we saw, etc
}
Then combine several gestures into one to cover the bases that we didn't specify
let drag = DragGesture(minimumDistance: 0)
.onChanged({ drag in
// Do stuff with the drag - maybe record what the value is in case things get lost later on
})
.onEnded({ drag in
self.onDragEnded()
})
let hackyPinch = MagnificationGesture(minimumScaleDelta: 0.0)
.onChanged({ delta in
self.onDragEnded()
})
.onEnded({ delta in
self.onDragEnded()
})
let hackyRotation = RotationGesture(minimumAngleDelta: Angle(degrees: 0.0))
.onChanged({ delta in
self.onDragEnded()
})
.onEnded({ delta in
self.onDragEnded()
})
let hackyPress = LongPressGesture(minimumDuration: 0.0, maximumDistance: 0.0)
.onChanged({ _ in
self.onDragEnded()
})
.onEnded({ delta in
self.onDragEnded()
})
let combinedGesture = drag
.simultaneously(with: hackyPinch)
.simultaneously(with: hackyRotation)
.exclusively(before: hackyPress)
/// The pinch and rotation may not be needed - in my case I don't but
/// obviously this might be very dependent on what you want to achieve
There might be a better combo for simultaneously and exclusively but for my use case at least (which is for something similar to a joystick) this seems like it is doing the job
There is also a GestureMask type that might have done the job but there is no documentation on how that works.
One solution is to use a #GestureState property that tracks if the drag is currently running. The state will be reset to false automatically when the gesture is cancelled.
struct DragSampleView: View {
#GestureState private var dragGestureActive: Bool = false
#State var dragOffset: CGSize = .zero
var draggingView: some View {
Text("DRAG ME").padding(50).background(.red)
}
var body: some View {
ZStack {
Color.blue.ignoresSafeArea()
draggingView
.offset(dragOffset)
.gesture(DragGesture()
.updating($dragGestureActive) { value, state, transaction in
state = true
}
.onChanged { value in
print("onChanged")
dragOffset = value.translation
}.onEnded { value in
print("onEnded")
dragOffset = .zero
})
.onChange(of: dragGestureActive) { newIsActiveValue in
if newIsActiveValue == false {
dragCancelled()
}
}
}
}
private func dragCancelled() {
print("dragCancelled")
dragOffset = .zero
}
}
struct DragV_PreviewProvider: PreviewProvider {
static var previews: some View {
DragSampleView()
}
}
See https://developer.apple.com/documentation/swiftui/draggesture/updating(_:body:)

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