0

I'm trying to create a scrollview with a custom navigator in the bottom. The navigation item should get a background when the scrollview is inside it's proximity.

I've used a scrollviewReader to save the item and the yOffSet inside an array. Then I've given a YOffsetScrollValuePreferenceKey to the entire HStack inside the scrollview. Lastly I listen to if YOffsetScrollValuePreferenceKey value changes, and if it does compare the new value with the value of the items inside the array. If the value exists then I set the selected item to the item belonging to that offset.

My problem occurs when I change the orientation of the device. If a user has scrolled let's say for example to the middle of the list, the position of the items will be calculated from that position. This means that instead of the first item having a yOffSet of 0, it now has a negative number (based on how far the user has scrolled). I need the items yOffSet to be calculated based on their position inside the scrollview, not based on where the user is inside the scrollview. Is there a way to do this?

I've already tried to let the scrollview scroll back to the first item upon change of orientation. This solution did not work, as the position of the item changed when the orientation changed, and this gave some other buggy behaviour. I've ran all out of ideas so hoped someone could help me here. :)

I will provide simple code where the problem is isolated below! If you have any more questions or need me to provide more information please let me know. To run into the problem run the code, scroll the list to the middle (or anywhere else apart from the starting position) change the orientation of the device, and scroll to a different section. The navigation view under the scrollview now does not run in synch with which view is on screen.

import SwiftUI

struct ContentView: View {
    @State private var numberPreferenceKeys = [NumberPreferenceKey]()
    @State var selectedNumber = 0
    @State var rectangleHeight: [CGFloat] = [
        CGFloat.random(in: 500..<2000),
        CGFloat.random(in: 500..<2000),
        CGFloat.random(in: 500..<2000),
        CGFloat.random(in: 500..<2000),
        CGFloat.random(in: 500..<2000)
    ]
    
    let colors: [Color] = [Color.blue, Color.red, Color.green, Color.gray, Color.purple]
    
    var body: some View {
        VStack {
            ScrollViewReader { reader in
                ScrollView(.horizontal) {
                    HStack {
                        ForEach(0..<5) { number in
                            Rectangle()
                                .fill(colors[number])
                                .frame(width: rectangleHeight[number], height: 200)
                                .id("\(number)")
                                .background(
                                    GeometryReader { proxy in
                                        if numberPreferenceKeys.count < 6{
                                            var yOffSet = proxy.frame(in: .named("number")).minX
                                            let _ = DispatchQueue.main.async {
                                                var yPositiveOffset: CGFloat = 0
                                                if number == 1, yOffSet < 0 {
                                                    yPositiveOffset = abs(yOffSet)
                                                }
                                                numberPreferenceKeys.append(
                                                    NumberPreferenceKey(
                                                        number: number,
                                                        yOffset: yOffSet + yPositiveOffset
                                                    )
                                                )
                                            }
                                        }
                                        Color.clear
                                    }
                                )
                        }
                    }
                    .background(GeometryReader {
                        Color.clear.preference(
                            key: YOffsetScrollValuePreferenceKey.self,
                            value: -$0.frame(in: .named("number")).origin.x
                        )
                    })
                    .onPreferenceChange(YOffsetScrollValuePreferenceKey.self) { viewYOffsetKey in
                        DispatchQueue.main.async {
                            for numberPreferenceKey in numberPreferenceKeys where numberPreferenceKey.yOffset <= viewYOffsetKey {
                                selectedNumber = numberPreferenceKey.number
                            }
                        }
                    }
                }
                
                HStack {
                    ForEach(0..<5) { number in
                        ZStack {
                            if number == selectedNumber {
                                Rectangle()
                                    .frame(width: 30, height: 30)
                            }
                            Rectangle()
                                .fill(colors[number])
                                .frame(width: 25, height: 25)
                                .onTapGesture {
                                    withAnimation {
                                        reader.scrollTo("\(number)")
                                    }
                                }
                        }
                    }
                }
            }
            .coordinateSpace(name: "number")
        }
        .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
            numberPreferenceKeys = []
        }
    }
}
struct NumberPreferenceKey {
    let number: Int
    let yOffset: CGFloat
}
struct YOffsetScrollValuePreferenceKey: PreferenceKey {
    typealias Value = CGFloat
    static var defaultValue = CGFloat.zero
    
    static func reduce(value: inout Value, nextValue: () -> Value) {
        value += nextValue()
    }
}
  • Not really sure if I got your intention - do you want to preserve offset or currently active view (or something third)? Anyway I would go different way cause offset is really dependent on many things that can be changed in OS (not only orientation). I would save "selection" and just change it depending on recalculations. This might be helpful https://stackoverflow.com/a/65871577/12299030. – Asperi Jun 27 '22 at 15:45

1 Answers1

0

This can be done in a much easier way using anchorPreference, then you only need one preference and there is no need to use a coordinateSpace.

I've updated your code to use this instead. It solves the issue you are having, just note that it will not re-center the currently scrolled to item on rotation, but it does change the selection if it changed because of rotation.

struct ContentView: View {
    
    // MARK: - Private Vars
    
    @State private var selectedNumber = 0
    
    private let rectangleHeight: [CGFloat] = [
        CGFloat.random(in: 500..<2000),
        CGFloat.random(in: 500..<2000),
        CGFloat.random(in: 500..<2000),
        CGFloat.random(in: 500..<2000),
        CGFloat.random(in: 500..<2000)
    ]
    
    private let colors: [Color] = [
        .blue,
        .red,
        .green,
        .gray,
        .purple
    ]
    
    // MARK: - View
    
    var body: some View {
        ScrollViewReader { reader in
            ScrollView(.horizontal) {
                HStack {
                    ForEach(0..<5) { index in
                        item(atIndex: index)
                    }
                }
            }
            .overlayPreferenceValue(ItemLeadingPreferenceKey.self) { anchors in
                GeometryReader { proxy in
                    // Find the index of the last anchor for which the x value is <= 0
                    // (indicating that it scrolled passed the beginning of the view)
                    let index = anchors.lastIndex(where: { proxy[$0].x <= 0 }) ?? 0

                    // Use this index to update the selected number
                    Color.clear
                        .onAppear {
                            selectedNumber = index
                        }
                        .onChange(of: index) {
                            selectedNumber = $0
                        }
                }
                .ignoresSafeArea()
            }
            
            footer(for: reader)
        }
    }
    
    // MARK: - Utils
    
    @ViewBuilder
    private func item(atIndex index: Int) -> some View {
        Rectangle()
            .fill(colors[index])
            .frame(width: rectangleHeight[index], height: 200)
            .id(index)
            .background {
                GeometryReader { proxy in
                    // Use the leading of this view for offset calculation
                    // You can also use center if that makes more sense for selection determination
                    Color.clear
                        .anchorPreference(key: ItemLeadingPreferenceKey.self, value: .leading) { [$0] }
                }
            }
    }
    
    @ViewBuilder
    private func footer(for proxy: ScrollViewProxy) -> some View {
        HStack {
            ForEach(0..<5) { index in
                ZStack {
                    if index == selectedNumber {
                        Rectangle()
                            .frame(width: 30, height: 30)
                    }
                    Rectangle()
                        .fill(colors[index])
                        .frame(width: 25, height: 25)
                        .onTapGesture {
                            withAnimation {
                                proxy.scrollTo(index, anchor: .leading)
                            }
                        }
                }
            }
        }
    }
}

struct ItemLeadingPreferenceKey: PreferenceKey {
    static let defaultValue: [Anchor<CGPoint>] = []
    
    static func reduce(value: inout [Anchor<CGPoint>], nextValue: () -> [Anchor<CGPoint>]) {
        value.append(contentsOf: nextValue())
    }
}
Steven
  • 419
  • 4
  • 6