[recovery mode] SwiftUI ScrollView и параллакс без тормозов

Всем привет! Меня зовут Николай, я iOS-разработчик.

Передо мной возникла задача сделать скроллируемый контент, на заднем плане которого прокручивается другой контент. Прокрутка должна быть синхронной, но с замедленной скоростью — как отдалённый фон в мультфильмах или играх.

Итак, начинаем.

В классическом UIScrollView из UIKit можно реализовать протокол UIScrollViewDelegate — метод scrollViewDidScroll (_ scrollView: UIScrollView) скажет нам, насколько сместился основной контент. Но в SwiftUI ScrollView не имеет делегата, поэтому ловить изменения нужно другими способами.

Я нашёл способ обрабатывать смещение — GeometryReader внутри 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
                }
            }
        }
    }
}

Однако GeometryReader не позволяет писать что-то в State-переменные. Компилятор на такой код ругается

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

Для того, чтобы передавать значение в State-переменную, потребовалось использовать PreferenceKey:

struct ScrollOffsetPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = 0

    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

Меняем содержимое GeometryReader:

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

Это вынос значения смещения в PreferenceKey. Кроме этого, надо ещё записать значение в State-переменную:

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

Компилятор не ругается. Теперь надо проверить работоспособность. Для этого делаем следующее:

  • Добавляем контент, чтобы было что скроллить, и это было заметно. Сразу выносим его из body:

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)
}
var body: some View {
    VStack {
        Text("\(scrollOffset)") // <-- этот элемент покажет нам значение смещения
        ScrollView {
            // ...

Смотрим что получилось

uzjff9b-k-m_h8eylkj7nmwlcfk.gif

Отлично, контент скроллится — значение ловится. Меняем пробники на рабочий код.

Главная вьюшка

struct ScrollOffsetPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = 0

    static func reduce(value: inout CGFloat, nextValue: () -> 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 > -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&cs=tinysrgb&dpr=1&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&cs=tinysrgb&dpr=1&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)
        }
    }
}

Заголовок скроллируемого контента

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

И верхняя вьюшка

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&cs=tinysrgb&dpr=1&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&cs=tinysrgb&dpr=1&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&cs=tinysrgb&dpr=1&w=500"))
                            .resizable()
                            .aspectRatio(contentMode: .fill)
                            .frame(width: imageSize.width, height: imageSize.height)
                            .clipped()
                            .cornerRadius(12)
                    }
                }
                .offset(x: 48)
            }
        }
    }

    private func cardBody(size: CGSize, color: Color, @ViewBuilder content: () -> Content) -> 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)
        }
    }
}

Запускаем

Работает как задумано!

Продолжаем.

Наполняем вьюшку боевым контентом:

Заменяем содержимое 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()
    }
}

Запускаем

Вроде всё норм, но заметно, что приложению поплохело — скролл лагает. Возможно, вьюшка слишком перегружена контентом, однако предыдущая версия, созданная в классическом UIKit, не тормозила.

Запустил профилировщик SwiftUI

В профилировщике я узнал, что мои элементы очень долго отрисовываются. Но зачем их рендерить постоянно, ведь они не меняются? Оказалось, что рендеринг в SwiftUI происходит при изменении @State-переменных. А в неё как раз и записывается смещение ScrollView. То есть рендер у меня происходит каждый раз, когда я прокручиваю контент пальцем по экрану!

Не очень весело. Пошёл в гугл искать решения, но большинство из них были однотипны и похожи на моё.

Наткнулся на статью, где использовали наследника ScrollView, но при попытке применить ту магию я столкнулся с той же проблемой — простой контент скроллится плавно, а то что посложнее — уже с тормозами.

Как я решил проблему.

После того как я перебрал кучу статей, погоревал о возможно впустую потраченном времени (был шанс остаться без вознаграждения — кому нужно тормозящее приложение?), мне в голову пришла идея. Зачем использовать @State-переменные, если можно не использовать?

Суть идеи в том, что смещение ScrollView отлавливает GeometryReader. Но ведь результат из него можно не передавать наружу, родительской вьюшке, а давать дочерним вьюшкам!

Итак, приступим:

Перемещаем элементы, которые нужно смещать с другой скоростью, внутрь 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
        }
    }
}

Также пришлось переделать подсчёт уменьшенного смещения

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
}

Ведь раньше отступ был от корневой вьюшки, а теперь от контента ScrollView.

Запускаем

Ну вот, теперь стало намного плавнее.

Заключение

Надеюсь, что моё решение поможет кому-нибудь ещё, а также что оно будет улучшего и оптимизировано.

Я выложил исходный код на всеобщее обозрение в репозиторий и разбил этапы по веткам. Начало в ветке start, скролл с тормозами — в ветке freezing_scroll, плавный скролл — в ветке smooth_scroll. Смотрите, пользуйтесь, критикуйте, исправляйте!

© Habrahabr.ru