I've been seeing some strange behavior for preference keys with ScrollView. If I put the onPreferenceChange
inside the ScrollView
it won't be called, but if I put it outside it does!
I've setup a width preference key as follows:
struct WidthPreferenceKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat(0)
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
The following simple view does not print:
struct ContentView: View {
var body: some View {
ScrollView {
Text("Hello")
.preference(key: WidthPreferenceKey.self, value: 20)
.onPreferenceChange(WidthPreferenceKey.self) {
print($0) // Not being called, we're in a scroll view.
}
}
}
}
But this works:
struct ContentView: View {
var body: some View {
ScrollView {
Text("Hello")
.preference(key: WidthPreferenceKey.self, value: 20)
}
.onPreferenceChange(WidthPreferenceKey.self) {
print($0)
}
}
}
I know that I can use the latter approach to fix this, but sometimes I'm inside a child view that does not have access to its parent scroll view but I still want to record a preference key.
Any ideas on how to get onPreferenceChange
to get called inside a ScrollView
?
Note: I get
Bound preference WidthPreferenceKey tried to update multiple times per frame.
when I put the function inside the scroll view, which might explain what is going on but I can't figure it out.
Thanks!
It seems like the issue is not necessarily with
ScrollView
, but with your usage ofPreferenceKey
. For instance, here is a sample struct in which aPreferenceKey
is set according to the width of aRectangle
, and then printed using.onPreferenceChange()
, all inside of aScrollView
. As you drag theSlider
to change the width, the key is updated and the print closure is executed.As you noted, the first time the key tries to set, the console prints "Bound preference WidthPreferenceKey tried to update multiple times per frame," but a real value is immediately set afterward, and it continues to update dynamically.
What value are you actually trying to set, and what are you trying to do in
.onPreferenceChange()
?You may only read it in superView, but you can change it with
transformPreference
after you set it .The last value is
30
now. Hope it is what you want.You can read from other layer:
I had been trying figure out this issue for a long time and have found how to deal with it although the way I used was just the one of workarounds.
Or,
Use
List
instead of it.It has the scroll feature and you can customize it with its own functionality and UITableView appearance in terms of UI. the most important is that it works as we expected.
[If you have time to read more]
Let me say my thought about that issue.
I have confirmed that
onPreferenceChange
isn't called at the bootstrap time of a view which is put inside aScrollView
. I'm not sure if it is right behavior or not. But, I assume that it's wrong becauseScrollView
have to be capable to contain any views even if some of those use PreferenceKey to pass any data among views inside it. If it's right behavior, it would be quite easy of us to get in trouble when we try to create our custom views.Let's get into more detail.
I suppose that
ScrollView
would work slightly different from the other container views such as List, (H/V)Stack when it comes to setup its child view at the bootstrap time. In other words,ScrollView
would try to draw(or lay out) children in its own way. Unfortunately, that way would affect the children's layout mechanism working incorrectly as what we've been seeing. We could guess what happened with the following message on debug view.It might be an evidence to tell us that the update of children has occurred while
ScrollView
is doing something for its setup. At that moment, it could be guessed that the update to PreferenceKey has been ignored.That's why I tried to put the placing child views off to
onAppear
.I hope that will be useful for someone who's struggling with various issue on SwiftUI.
I think onPreferenceChange in your example is not called because it’s function is profoundly different from preference(key…)
preference(key:..) sets a preference value for the view it is used on. whereas onPreferenceChange is a function called on a parent view – a view on a higher position in the view tree hierarchy. Its function is to go through all its children and sub-children and collect their preference(key:) values. When it found one it will use the reduce function from the PreferenceKey on this new value and all the already collected values. Once it has all the values collected and reduced them it will execute the onPreference closure on the result.
In your first example this closure is never called because the Text(“Hello”) view has no children which set the preference key value (in fact the view has no children at all). In your second example the Scroll view has a child which sets its preference value (the Text view).
All this does not explain the multiple times per frame error – which is most likely unrelated.
Recent update (24.4.2020): In a similar case I could induce the call of onPreferenceChange by changing the Equatable condition for the PreferenceData. PreferenceData needs to be Equatable (probably to detect a change in them). However, the Anchor type by itself is not equatable any longer. To extract the values enclosed in an Anchor type a GeometryProxy is required. You get a GeometryProxy via a GeometryReader. For not disturbing the design of views by enclosing some of them into a GeometryReader I generated one in the equatable function of the PreferenceData struct:
With kind regards