Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why isn't onPreferenceChange being called if it's inside a ScrollView in SwiftUI?

Tags:

swiftui

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!

like image 218
McGuire Avatar asked Nov 05 '19 22:11

McGuire


2 Answers

I had been trying to figure out this issue for a long time and have found how to deal with it, although the way I used was just one of the workarounds.

Use onAppear to ScrollView with a flag to make its children show up.

...
@State var isShowingContent = false
...

ScrollView {
    if isShowingContent {
        ContentView()
    }
}
.onAppear {
     self.isShowingContent = true
}

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 put inside a ScrollView. I'm not sure if it is the right behavior or not. But, I assume that it's wrong because ScrollView has to be capable of containing any views even if some of those use PreferenceKey to pass any data among views inside it. If it's the right behavior, it would be quite easy for us to get in trouble when creating 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 set up 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.

TestHPreferenceKey tried to update multiple times per frame.

It might be a piece of 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 issues on SwiftUI.

like image 134
Kyokook Hwang Avatar answered Oct 04 '22 01:10

Kyokook Hwang


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:

struct ParagraphSizeData: Equatable {
  let paragraphRect: Anchor<CGRect>?

  static func == (value1: ParagraphSizeData, value2: ParagraphSizeData) -> Bool {

    var theResult : Bool = false
    let _ = GeometryReader { geometry in
       generateView(geometry:geometry, equality:&theResult)
    }

    func generateView(geometry: GeometryProxy, equality: inout Bool) -> Rectangle {
       let paragraphSize1, paragraphSize2: NSSize

      if let anAnchor = value1.paragraphRect { paragraphSize1 = geometry[anAnchor].size }
       else {paragraphSize1 = NSZeroSize }
       if let anAnchor = value2.paragraphRect { paragraphSize2 = geometry[anAnchor].size }
       else {paragraphSize2 = NSZeroSize }

       equality = (paragraphSize1 == paragraphSize2)
       return Rectangle()
    }

   return theResult     
  }

}

With kind regards

like image 28
M Wilm Avatar answered Oct 04 '22 00:10

M Wilm