Pull to refresh

SwiftUI ScrollView and non-freezing parallax

Reading time8 min
Views2.3K

Hello everyone! My name is Nikolai, I'm iOS developer.

I have been given the task of creating scrollable content, with another one in the background. Both should scroll synchronously but the background should scroll more slowly - like background images in cartoons or videogames.

So, let's begin.

Classic UIKit UIScrollView has the protocol UIScrollViewDelegate - with a method scrollViewDidScroll(_ scrollView: UIScrollView) which will report the scroll offset. But SwiftUI's ScrollView doesn't have such a delegate, and we have to catch scrolling in some other way.

I found a way to handle scrolling - use GeometryReader inside ScrollView:

struct ContentView: View {
    @State private var scrollOffset = CGFloat(0)
    var body: some View {
        ScrollView {
            ZStack {
                Color.green
                    .opacity(0.5)
                    .frame(height: UIScreen.main.bounds.height * 3)

                GeometryReader { proxy in
                    let offset = proxy.frame(in: .named("scroll")).minY
                    scrollOffset = offset
                }
            }
        }
    }
}

But GeometryReader doesn't allow changing the values of State variables. Here is the compiler error message:

"Type '()' cannot conform to 'View'

If we want to change the value of the State variable, we should use PreferenceKey:

struct ScrollOffsetPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

Edit the code of the GeometryReader:

GeometryReader { proxy in
    let offset = proxy.frame(in: .named("scroll")).minY
    Color.clear.preference(
        key: ScrollOffsetPreferenceKey.self,
        value: offset
    )
}

We moved out the value of the scrolling offset in the PreferenceKey. Besides that, we should write a value to the State variable:

.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
    scrollOffset = value
}

The compiler doesn't show any errors. Now we can check how it works. Our steps:

  • Add scrollable content. Move it out frombody:

var body: some View {
    VStack {
        ScrollView {
            ZStack {
                VStack {
                    scrollViewContentBody
                }
                .opacity(0.75)
                GeometryReader { proxy in
                    let offset = proxy.frame(in: .named("scroll")).minY
                    Color.clear.preference(
                        key: ScrollOffsetPreferenceKey.self,
                        value: offset
                    )
            }
        }
    }
    .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
        scrollOffset = value
    }

}

@ViewBuilder
private var scrollViewContentBody: some View {
    Text("Lorem ipsum")
        .font(.largeTitle)
        .padding(16)
    separator

    Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")
        .font(.title)
        .padding(16)

    separator

    Text("At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat.")
        .font(.title)
        .padding(16)

    separator
}

private var separator: some View {
    Color.gray
        .frame(height: 1 / UIScreen.main.scale)
        .padding(16)
}
  • Add a debugging element that will show us the value

var body: some View {
VStack {
Text("(scrollOffset)") // <-- этот элемент покажет нам значение смещения
ScrollView {
// ...
And look the result

Great, the content is scrolling, and the value is changing! Replace the debugging elements with real ones.

Main view
struct ScrollOffsetPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -&gt; CGFloat) {
        value = nextValue()
    }

}

struct RootView: View {
    @State private var scrollOffset = CGFloat(0)
    @State private var rate: Decimal? = 3.8
    // MARK: - Vars

    private var scrolledEnoughToShowTopBar: Bool {
        -scrollOffset &gt; -90
    }

    private var topBarHeight: CGFloat {
        UIApplication.shared.safeAreaInsets.top + 42
    }

    private var topBackgroundOffset: CGFloat {
        let bounds = UIScreen.main.bounds
        return scrollOffset / 10 - bounds.height / 8
    }

    private var topImageOffset: CGFloat {
        let bounds = UIScreen.main.bounds
        return scrollOffset / 5 - bounds.height / 20
    }

    // MARK: - UI

    @ViewBuilder
    var body: some View {
        let screenBounds = UIScreen.main.bounds
        ZStack(alignment: .top) {
            AnimatedImage(url: URL(string: "https://images.pexels.com/photos/8856514/pexels-photo-8856514.jpeg?auto=compress&amp;cs=tinysrgb&amp;dpr=1&amp;w=500"))
                .blur(radius: 4)
                .frame(width: screenBounds.width, height: screenBounds.height)
                .aspectRatio(contentMode: .fill)
                .clipped()

            Group {
                topBackgroundBody
                EntryInfoView(imageOffset: topImageOffset)
                scrollBody.padding(.top, -200)
            }
        }
        .ignoresSafeArea(.all, edges: [.top, .bottom])
    }

    private var topBackgroundBody: some View {
        AnimatedImage(url: URL(string: "https://images.pexels.com/photos/10288317/pexels-photo-10288317.jpeg?auto=compress&amp;cs=tinysrgb&amp;dpr=1&amp;w=500"))
            .resizable()
            .aspectRatio(contentMode: .fill)
            .frame(width: UIScreen.main.bounds.width,
                   height: UIScreen.main.bounds.height / 1.5)
            .aspectRatio(contentMode: .fill)
            .clipped()
            .offset(y: topBackgroundOffset)
    }

    @ViewBuilder
    private var scrollBody: some View {
        let size = UIScreen.main.bounds
        ScrollView {
            LazyVStack {
                Color.clear
                    .frame(width: size.width, height: size.height - 40)
                ZStack(alignment: .top) {
                    scrollContentBody

                    GeometryReader { proxy in
                        let offset = proxy.frame(in: .named("scroll")).minY
                        Text("\(offset)")
                        Color.clear.preference(key: ScrollOffsetPreferenceKey.self, value: offset)
                    }
                }
                Color.clear
                    .frame(width: size.width,
                           height: UIApplication.shared.safeAreaInsets.bottom)
            }
        }
        .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
            scrollOffset = value
        }
    }

    private var scrollContentBody: some View {
        LazyVStack(spacing: 0) {
            EntryScrollHeader()

            Color.green.opacity(0.5)
                .frame(height: 200)

            Color.red.opacity(0.5)
            .frame(height: 1000)
        }
    }

}

Title of the scrolling content
struct EntryScrollHeader: View {
    var body: some View {
        VStack(spacing: 0) {
            HStack {
                VStack {
                    Text("Description")
                }
                Spacer()

                Button {
                } label: {
                    Image(systemSymbol: .bookmark)
                        .foregroundColor(.black.opacity(0.25))
                        .font(.system(size: 25, weight: .regular, design: .default))
                }
            }
            .padding(.bottom, 4)

            HStack {
                VStack {
                    Text("Lorem")
                    Text("Ipsum")
                }
                Spacer()
            }

        }
        .frame(height: 76)
        .padding([.leading, .trailing], 15)
        .padding(.top, 12)
        .background(Color.white)
    }

}

Top view
struct EntryInfoView: View {
    private var imageOffset: CGFloat
    init(imageOffset: CGFloat) {
        self.imageOffset = imageOffset
    }

    @ViewBuilder
    var body: some View {
        let screenBounds = UIScreen.main.bounds

        ZStack(alignment: .topLeading) {
            HStack {
                infoBody

                cardsBody
                    .frame(height: screenBounds.height / 4)
                    .padding(8)
            }
            .padding(16)
        }
        .offset(y: imageOffset)
    }

    @ViewBuilder
    private var infoBody: some View {
        let screenBounds = UIScreen.main.bounds
        let rating = Decimal(4.2)

        VStack(spacing: 0) {
            Text(rating.ratingString)
                .font(.system(size: 24))
                .padding(.top, 8)

            Text("49,849")
                .font(.system(size: 10))
                .padding(.top, 4)

            EntryLittleStarsView(rating: rating)
                .foregroundColor(.orange)
                .padding(4)
        }
        .frame(minHeight: screenBounds.width / 3)
        .background(Color.white)
        .cornerRadius(8)
    }

    private var cardsBody: some View {
        GeometryReader { proxy in
            let size = CGSize(width: proxy.size.width - 48,
                              height: proxy.size.height - 48)
            let imageSize = CGSize(width: proxy.size.width - 64,
                                   height: proxy.size.height - 64)
            ZStack {
                cardBody(size: size, color: .red) {
                    ZStack {
                        Color.white

                        AnimatedImage(url: URL(string: "https://images.pexels.com/photos/10136037/pexels-photo-10136037.jpeg?auto=compress&amp;cs=tinysrgb&amp;dpr=1&amp;w=500"))
                            .resizable()
                            .blur(radius: 2)
                            .aspectRatio(contentMode: .fill)
                            .frame(width: imageSize.width, height: imageSize.height)
                            .clipped()
                            .cornerRadius(12)
                    }
                }
                .offset(y: 48)

                cardBody(size: size, color: .green) {
                    ZStack {
                        Color.white

                        AnimatedImage(url: URL(string: "https://images.pexels.com/photos/10243803/pexels-photo-10243803.jpeg?auto=compress&amp;cs=tinysrgb&amp;dpr=1&amp;w=500"))
                            .resizable()
                            .blur(radius: 2)
                            .aspectRatio(contentMode: .fill)
                            .frame(width: imageSize.width, height: imageSize.height)
                            .clipped()
                            .cornerRadius(12)
                    }
                }
                .offset(x: 24, y: 24)

                cardBody(size: size, color: .blue) {
                    ZStack {
                        Color.white

                        AnimatedImage(url: URL(string: "https://images.pexels.com/photos/10278313/pexels-photo-10278313.jpeg?auto=compress&amp;cs=tinysrgb&amp;dpr=1&amp;w=500"))
                            .resizable()
                            .aspectRatio(contentMode: .fill)
                            .frame(width: imageSize.width, height: imageSize.height)
                            .clipped()
                            .cornerRadius(12)
                    }
                }
                .offset(x: 48)
            }
        }
    }

    private func cardBody&lt;Content: View&gt;(size: CGSize, color: Color, @ViewBuilder content: () -&gt; Content) -&gt; some View {
        ZStack {
            color
                .opacity(0.75)
                .frame(width: size.width, height: size.height)
                .cornerRadius(16)
            content()
                .frame(width: size.width - 8,
                       height: size.height - 8)
                .cornerRadius(14)
        }
        .shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2)
    }

    private var priceBody: some View {
        VStack {
            ZStack {
                Image(systemSymbol: .circleSquare)
                HStack(alignment: .top, spacing: 0) {
                    Text("$")
                        .font(.system(size: 9))
                    Text("497")
                        .font(.system(size: 20))
                }
            }
            HStack(spacing: 0) {
                Image(systemSymbol: .plusMessage)
                Text("Average price")
            }
            .font(.system(size: 10))
            .opacity(0.25)
        }
    }

}

Launch

Great, it works as desired!

Let's continue.

Fill the view with real content:

Replace content of the scrollContentBody
LazyVStack(spacing: 0) {
    EntryScrollHeader()
    ZStack {
        VStack(spacing: 0) {
            hugeSeparator
            separator
            hugeSeparator
            RateView(rate: $rate, swipeEnabled: true)
            hugeSeparator
            EntryItemPairingView()
            EntryAboutView()
        }
    }

    VStack(spacing: 0) {
        EntryNotesView()
        EntryMapView()
        EntryBestItemsView()
        EntryLatestCommentsView()
    }

}

Launch

It looks okay, but we can see that delays appeared. We can suppose that view is overloaded with content. However, the previous version made using classic UIKit worked perfectly.

Launch the SwiftUI profiler

Profiler reports to us that scrolling elements take a lot of time to render. But why do they render constantly? We don't change them! It turned out that SwiftUI renders the view every time the State variable changes its value. And we just place the ScrollView offset in the State variable. That is, rendering launches every time I scroll the view!

Not so good. I went to Google to find a solution, but most of them were the same as mine.

I found the article with ScrollView inheritor, but when that magic was applied, the same problem occurred - simple content scrolls smoothly, and hard one - with freezes.

How I solved the problem.

After researching a number of articles I almost gave up. I may stay without earning - no one needs a freezing app. But at the last moment, the idea came to me! Why should we use State variables if we can not use them?

The point of the idea is that GeometryReader catches scrolling offset. But we can give the result in child views instead of the parent's!

So, let's begin again:

Move elements that should be scrolled with another speed, inside GeometryReader
ScrollView {
    LazyVStack {
        Color.clear
            .frame(width: size.width, height: size.height - 40)
        ZStack(alignment: .top) {
            GeometryReader { proxy in
                let offset = proxy.frame(in: .named("scroll")).minY
                Text("(offset)")
                topBackgroundBody(offset: offset)
                EntryInfoView(imageOffset: topImageOffset(scrollOffset: offset))
            }
            scrollContentBody
        }
    }
}

Also decreased offset calculation should be changed
private func topBackgroundOffset(scrollOffset: CGFloat) -> CGFloat {
    min(-scrollOffset, -scrollOffset * 0.9 - topBarHeight)
}

private func topImageOffset(scrollOffset: CGFloat) -> CGFloat {
    let bounds = UIScreen.main.bounds
    return -scrollOffset * 0.8 - bounds.height / 20
}

Firstly, the offset was from the root view, while now - from ScrollView content.

Launch

Hooray, it's much smoother now!

Conclusion

I hope that my solution will be useful to someone else. And maybe updated and optimized.

I have published the source code to the repo and placed the stages in different branches. The beginning is in the start branch, freezing scrolling - in freezing_scroll, and the final result with smooth scrolling - in smooth_scroll. Look, use, criticize, and improve!

Tags:
Hubs:
Rating0
Comments1

Articles