swiftui

scrollPosition with center anchor gets wrong item


I'm trying to make a horizontal scrolling view that sends the centered view's ID to the parent view via a binding on scrollPosition(id:anchor:), but it's getting the wrong item at the center.

Here's a sample:

struct ScrollPositionSample: View {
    let words = "Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod".components(separatedBy: " ")
    @State private var selection: String? = "Lorem"
    

    var body: some View {
        VStack {
            if let selection {
                Text("Selected: \(selection)")
                    .font(.title3)
            }

            ScrollView(.horizontal) {
                LazyHStack {
                    ForEach(words, id: \.self) { word in
                        Text(word)
                            .font(.title2)
                            .padding()
                            .background(.teal, in: RoundedRectangle(cornerRadius: 5.0))
                    }
                }
                .scrollTargetLayout()
                .frame(maxHeight: 125)
            }
            .scrollPosition(id: $selection, anchor: .center)
            .contentMargins(.leading, 100) // see note below
            .contentMargins(.trailing, 40) // about contentMargins
            .overlay {
                // line centered over scrollView to visualize centered item
                Color.red.frame(width: 2)
            }
        }
    }
}

The problem is that the centered overlay line often doesn't match the selection string displayed above the scrollView. It's usually close, but gets worse if I set horizontal contentMargins on the scrollView (regardless of if they're equal or not).

ScrollView centering fail

Is there some trick to getting the centered value right?


Solution

  • The reason for the issue may be because you are using a lazy container to hold items that have irregular widths. Item positioning does not work very well when this is the case.

    A workaround is to detect the item under the center position yourself. This is not too difficult if you know the width of the ScrollView.

    Here is the updated example to show it working this way:

    struct ScrollPositionSample: View {
        let words = "Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod".components(separatedBy: " ")
        let spacing: CGFloat = 10
        @State private var selection: String?
        
        var body: some View {
            VStack {
                if let selection {
                    Text("Selected: \(selection)")
                        .font(.title3)
                }
                
                GeometryReader { outer in
                    let midScroll = outer.size.width / 2
                    ScrollView(.horizontal) {
                        LazyHStack(spacing: spacing) {
                            ForEach(words, id: \.self) { word in
                                Text(word)
                                    .font(.title2)
                                    .padding()
                                    .background(.teal, in: RoundedRectangle(cornerRadius: 5.0))
                                    .onGeometryChange(for: Bool.self) { geo in
                                        let midX = geo.frame(in: .scrollView).midX
                                        return abs(midX - midScroll) < (geo.size.width + spacing) / 2
                                    } action: { isBelowCenter in
                                        if isBelowCenter {
                                            selection = word
                                        }
                                    }
                            }
                        }
                        .padding(.horizontal, outer.size.width / 2)
                    }
                    .overlay {
                        Color.red.frame(width: 2)
                    }
                }
                .frame(maxHeight: 125)
            }
        }
    }
    

    Animation

    In your original example, you were shifting the center line by applying un-equal leading and trailing content margins. If you want to detect when an item is below some point that is not in the center then obviously you will need to change the detection logic, for example, by modifying the way that midScroll is computed.