24

I've got a simple view:

var body: some View {
        NavigationView {
            ScrollView {
                VStack {
                    ForEach(0..<2) { _ in
                        CardVew(for: cardData)
                    }
                }
            }
            .navigationBarTitle("Testing", displayMode: .automatic)
        }
    }

But you can replace the CardView with anything - the glitch presists. Glitch video

Is there a way to fix it?

Xcode 12.0.1, Swift 5

belotserkovtsev
  • 351
  • 2
  • 10

4 Answers4

32

Setting the top padding to 1 is breaking at least 2 major things:

  1. The scroll view does not extend under NavigationView and TabView - this making it loose the beautiful blur effect of the content that scrolls under the bars.
  2. Setting background on the scroll view will cause Large Title NavigationView to stop collapsing.

I've encountered these issues when i had to change the background color on all screens of the app i was working on. So i did a little bit more digging and experimenting and managed to figure out a pretty nice solution to the problem.

Here is the raw solution:

We wrap the ScrollView into 2 geometry readers.

The top one is respecting the safe area - we need this one in order to read the safe area insets The second is going full screen.

We put the scroll view into the second geometry reader - making it size to full screen.

Then we add the content using VStack, by applying safe area paddings.

At the end - we have scroll view that does not flicker and accepts background without breaking the large title of the navigation bar.

struct ContentView: View {
    var body: some View {
        NavigationView {
            GeometryReader { geometryWithSafeArea in
                GeometryReader { geometry in
                    ScrollView {
                        VStack {

                            Color.red.frame(width: 100, height: 100, alignment: .center)

                            ForEach(0..<5) { i in

                                Text("\(i)")
                                    .frame(maxWidth: .infinity)
                                    .background(Color.green)

                                Spacer()
                            }

                            Color.red.frame(width: 100, height: 100, alignment: .center)
                        }
                        .padding(.top, geometryWithSafeArea.safeAreaInsets.top)
                        .padding(.bottom, geometryWithSafeArea.safeAreaInsets.bottom)
                        .padding(.leading, geometryWithSafeArea.safeAreaInsets.leading)
                        .padding(.trailing, geometryWithSafeArea.safeAreaInsets.trailing)
                    }
                    .background(Color.yellow)
                }
                .edgesIgnoringSafeArea(.all)
            }
            .navigationBarTitle(Text("Example"))
        }
    }
}

The elegant solution

Since the solution is clear now - lets create an elegant solution that can be reused and applied to any existing ScrollView by just replacing the padding fix.

We create an extension of ScrollView that declares the fixFlickering function.

The logic is basically we wrap the receiver into the geometry readers and wrap its content into the VStack with the safe area paddings - that's it.

The ScrollView is used, because the compiler incorrectly infers the Content of the nested scroll view as should being the same as the receiver. Declaring AnyView explicitly will make it accept the wrapped content.

There are 2 overloads:

  • the first one does not accept any arguments and you can just call it on any of your existing scroll views, eg. you can replace the .padding(.top, 1) with .fixFlickering() - thats it.
  • the second one accept a configurator closure, which is used to give you the chance to setup the nested scroll view. Thats needed because we don't use the receiver and just wrap it, but we create a new instance of ScrollView and use only the receiver's configuration and content. In this closure you can modify the provided ScrollView in any way you would like, eg. setting a background color.
extension ScrollView {
    
    public func fixFlickering() -> some View {
        
        return self.fixFlickering { (scrollView) in
            
            return scrollView
        }
    }
    
    public func fixFlickering<T: View>(@ViewBuilder configurator: @escaping (ScrollView<AnyView>) -> T) -> some View {
        
        GeometryReader { geometryWithSafeArea in
            GeometryReader { geometry in
                configurator(
                ScrollView<AnyView>(self.axes, showsIndicators: self.showsIndicators) {
                    AnyView(
                    VStack {
                        self.content
                    }
                    .padding(.top, geometryWithSafeArea.safeAreaInsets.top)
                    .padding(.bottom, geometryWithSafeArea.safeAreaInsets.bottom)
                    .padding(.leading, geometryWithSafeArea.safeAreaInsets.leading)
                    .padding(.trailing, geometryWithSafeArea.safeAreaInsets.trailing)
                    )
                }
                )
            }
            .edgesIgnoringSafeArea(.all)
        }
    }
}

Example 1

struct ContentView: View {
    var body: some View {
        NavigationView {
            ScrollView {
                VStack {
                    Color.red.frame(width: 100, height: 100, alignment: .center)

                    ForEach(0..<5) { i in

                        Text("\(i)")
                            .frame(maxWidth: .infinity)
                            .background(Color.green)
                        
                        Spacer()
                    }

                    Color.red.frame(width: 100, height: 100, alignment: .center)
                }
            }
            .fixFlickering { scrollView in
                
                scrollView
                    .background(Color.yellow)
            }
            .navigationBarTitle(Text("Example"))
        }
    }
}

Example 2

struct ContentView: View {
    var body: some View {
        NavigationView {
            ScrollView {
                VStack {
                    Color.red.frame(width: 100, height: 100, alignment: .center)

                    ForEach(0..<5) { i in

                        Text("\(i)")
                            .frame(maxWidth: .infinity)
                            .background(Color.green)
                        
                        Spacer()
                    }

                    Color.red.frame(width: 100, height: 100, alignment: .center)
                }
            }
            .fixFlickering()
            .navigationBarTitle(Text("Example"))
        }
    }
}
aheze
  • 24,434
  • 8
  • 68
  • 125
KoCMoHaBTa
  • 1,519
  • 15
  • 16
18

Here's a workaround. Add .padding(.top, 1) to the ScrollView:

struct ContentView: View {
            
    var body: some View {
        NavigationView {
            ScrollView {
                VStack {
                    ForEach(0..<2) { _ in
                        Color.blue.frame(width: 350, height: 200)
                    }
                }
            }
            .padding(.top, 1)
            .navigationBarTitle("Testing", displayMode: .automatic)
        }
            
    }
}
vacawama
  • 150,663
  • 30
  • 266
  • 294
  • Thanks for posting this! I'd be curious what the thought process was that led you to that solution... – Peter Friese Nov 13 '20 at 11:37
  • 2
    @PeterFriese, I started with putting an extra view above the scrollView thinking there was an interaction between the scrollView and the navigationBar. That worked. I then reduced the size of that view finding it could be really short and still work. Since this view was acting as padding, I decided to try replacing it with padding and saw that that also worked. So the formula is one part intuition and one part trying stuff. – vacawama Nov 13 '20 at 13:19
  • 1
    @PeterFriese, if you look at the edit history you will see my first solution with the extra view. – vacawama Nov 13 '20 at 13:34
  • 1
    It’s crazy that after 2 years of swiftui we still need to have these workarounds! Anyway, thank you! – Rico Crescenzio Nov 17 '20 at 23:50
  • @RicoCrescenzio, yes I agree. It's still a bit rough around the edges at times. It took the Swift language 3 or 4 years to mature. – vacawama Nov 18 '20 at 00:00
  • 1
    Btw this breaks the scrollview extending content below the navigation view – KoCMoHaBTa Dec 09 '20 at 11:56
  • Thanks! a lot no more fixFlickering with NavigationView with large Titles! – finalpets Feb 10 '21 at 07:25
6

I simplified @KoCMoHaBTa's answer. Here it is:

extension ScrollView {
    private typealias PaddedContent = ModifiedContent<Content, _PaddingLayout>
    
    func fixFlickering() -> some View {
        GeometryReader { geo in
            ScrollView<PaddedContent>(axes, showsIndicators: showsIndicators) {
                content.padding(geo.safeAreaInsets) as! PaddedContent
            }
            .edgesIgnoringSafeArea(.all)
        }
    }
}

Use like so:

struct ContentView: View {
    var body: some View {
        NavigationView {
            ScrollView {
                /* ... */
            }
            .fixFlickering()
        }
    }
}
George
  • 25,988
  • 10
  • 79
  • 133
  • TIL about `_PaddingLayout` - is this documented anywhere? This seems more elegant but also more brittle. – steipete May 04 '21 at 16:28
  • @steipete I usually find things like this by printing the SwiftUI view, and reading the type it produces. I haven't found it documented anywhere. If you are interested, I have also found Xcode autocompleting initializers, such as [`AnyView(_fromValue:)`](https://stackoverflow.com/a/67243560/9607863), which I assume are meant to be private. – George May 04 '21 at 16:32
  • @steipete Although this solution is 'robust', I'm unsure if using something such as `_PaddingLayout` counts as a 'private API'. After all, the type would usually be inferred. If you are a bit unsure like I was, you can make something like `struct ContentAcceptingScrollView { ... }` which just creates a `ScrollView` in the body but the content type is inferred so we don't need this type cast. – George May 04 '21 at 16:39
3

while facing the same problem I did a bit of investigation. The glitch seems to come from a combination of scrollview bouncing, and the speed of deceleration of the scrolling context.

For now I have managed to make the glitch disappear by settings the deceleration rate to fast. It seem to let swiftui better compute the layout while keeping the bounce animation active.

My work around is as simple as to set the following in the init of your view. The drawback its that this affects the speed of your scrolling deceleration.

 init() {
    UIScrollView.appearance().decelerationRate = .fast
}

A possible improvement would be to compute the size of the content being displayed and then switch on the fly the deceleration rate depending what would be needed.

Martin
  • 843
  • 8
  • 17