skip to Main Content

I’m having trouble with a horizontal list in SwiftUI. The list has a conditional that allows it to be displayed or hidden, depending on the state of a toggle. The horizontal list is embedded in a vertical scroll view that contains other views as well, such as an code below, some text, and another horizontal list that is displayed under certain conditions.

The issue is that, when I scroll the first horizontal list and then it disappears because the toggle is turned off, and the second horizontal list appears, the scroll position of the first list is not being saved. I want to be able to save the scroll position of each list, so that when I toggle between them, their respective scroll positions are maintained.

I’ve tried using ScrollViewReader and PreferenceKey to save and restore the scroll position, but it’s not working. I’m not sure if I’m using these APIs correctly or if there’s something else that I’m missing.

Can anyone provide some guidance on how to solve this issue? Any help would be greatly appreciated!

import SwiftUI

struct ContentView: View {
    @State private var isList1Visible = true
    @State private var scrollPosition1: CGFloat = 0
    @State private var scrollPosition2: CGFloat = 0
    @State private var previousScrollPosition1: CGFloat = 0
    @State private var previousScrollPosition2: CGFloat = 0
    
    var body: some View {
        ScrollView {
            VStack {
                Image(systemName: "person.crop.circle.fill")
                    .resizable()
                    .frame(width: 100, height: 100)
                    .padding()
                
                Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed in velit magna. Donec laoreet felis sapien, vel pulvinar orci elementum quis.")
                    .padding()
                
                // First horizontal list
                if isList1Visible {
                    ScrollViewReader { proxyReader in
                        ScrollView(.horizontal) {
                            HStack {
                                ForEach(0..<10) { index in
                                    Text("(index)")
                                        .frame(width: 100, height: 100)
                                        .background(Color.blue)
                                        .cornerRadius(10)
                                        .id(index)
                                }
                            }
                            .padding(.horizontal, 10)
                            .background(GeometryReader { proxy in
                                Color.clear
                                    .preference(key: ScrollOffsetPreferenceKey.self, value: proxy.frame(in: .named("vertical_offset")).minX)
                            })
                            .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
                                scrollPosition1 = value
                            }
                        }
                        .coordinateSpace(name: "vertical_offset")
                        .frame(height: 150)
                        .onDisappear {
                            previousScrollPosition1 = scrollPosition1
                        }
                    }
                }
                
                Text("Aliquam eget semper ipsum, quis finibus quam. Nulla facilisi. Praesent rutrum sapien eu tortor commodo fringilla.")
                    .padding()
                
                // Second horizontal list
                if !isList1Visible {
                    ScrollView(.horizontal) {
                        HStack {
                            ForEach(0..<10) { index in
                                Text("(index)")
                                    .frame(width: 100, height: 100)
                                    .background(Color.green)
                                    .cornerRadius(10)
                                    .id(index)
                            }
                        }
                        .padding(.horizontal, 10)
                        .background(GeometryReader { proxy in
                            Color.clear
                                .preference(key: ScrollOffsetPreferenceKey.self, value: proxy.frame(in: .named("vertical_offset_2")).minX)
                        })
                        .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
                            scrollPosition2 = value
                        }
                    }
                    .coordinateSpace(name: "vertical_offset_2")
                    .frame(height: 150)
                    .onAppear {
                        scrollPosition2 = previousScrollPosition1
                    }
                    .onDisappear {
                        previousScrollPosition2 = scrollPosition2
                    }
                }
                
                Text("Etiam eget orci dolor. Fusce eu sapien euismod, pharetra est eget, consequat libero. Sed sed tristique nibh.")
                    .padding()
                
                // Button to toggle the visibility of the horizontal lists
                Button(action: {
                    isList1Visible.toggle()
                }) {
                    Text(isList1Visible ? "Show List 2" : "Show List 1")
                }
                
                Spacer()
            }
        }
    }
    
    struct ScrollOffsetPreferenceKey: PreferenceKey {
        typealias Value = CGFloat
        static var defaultValue: CGFloat = 0
        static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
            value = nextValue()
        }
    }
}

2

Answers


  1. You missed the main idea – this structure (View) will be taken as a code generating source on any changes in data. If your View will change -> the structure will be recreated.

    So, if you need to save some states or data – you should create some class "Model" and storage all data in it:

    struct ContentView: View {
        @ObservedObject var scrollModel: ScrollModel
        ....
    }
    class ScrollModel {
        @Published var scrollPosition1: CGFloat = 0
        @Published var scrollPosition2: CGFloat = 0
    }
    
    Login or Signup to reply.
  2. I guess you want this:

    two scroll views with synchronized content offsets

    Since you want to synchronize the scroll offsets between the two scroll views, which is a feature not offered by SwiftUI’s built-in ScrollView as of iOS 16.4, I suggest you wrap UIScrollView to let you associated a Binding<CGPoint> with the content offset. Here’s the code, specifically for a horizontally-scrolling scroll view:

    struct MyScrollView<Content: View>: View {
        @Binding var contentOffset: CGPoint
    
        @ViewBuilder
        let content: Content
    
        var body: some View {
            Wrapper(contentOffset: $contentOffset, content: content)
        }
    
        private struct Wrapper: UIViewControllerRepresentable {
            @Binding var contentOffset: CGPoint
            let content: Content
    
            func makeUIViewController(context: Context) -> Controller {
                return .init(
                    contentOffset: $contentOffset,
                    content: content
                )
            }
    
            func updateUIViewController(_ controller: Controller, context: Context) {
                controller.host.rootView = content
    
                if controller.scrollView.contentOffset != contentOffset {
                    controller.scrollView.contentOffset = contentOffset
                }
            }
    
            class Controller: UIViewController, UIScrollViewDelegate {
                let scrollView = UIScrollView()
                let host: UIHostingController<Content>
                var contentOffset: Binding<CGPoint>
    
                init(contentOffset: Binding<CGPoint>, content: Content) {
                    self.contentOffset = contentOffset
                    host = .init(rootView: content)
    
                    super.init(nibName: nil, bundle: nil)
    
                    scrollView.delegate = self
                    scrollView.translatesAutoresizingMaskIntoConstraints = false
                    host.view.translatesAutoresizingMaskIntoConstraints = false
    
                    self.view = scrollView
                    self.addChild(host)
                    scrollView.addSubview(host.view)
    
                    let contentView = host.view!
                    let frame = scrollView.frameLayoutGuide
                    let content = scrollView.contentLayoutGuide
                    NSLayoutConstraint.activate([
                        // Constrain the content view's height to the frame height of
                        // the scroll view.
                        contentView.topAnchor.constraint(equalTo: frame.topAnchor),
                        contentView.bottomAnchor.constraint(equalTo: frame.bottomAnchor),
    
                        // Constrain the scroll view's content area to the content view. This
                        // sets the scroll view's contentSize.
                        contentView.leadingAnchor.constraint(equalTo: content.leadingAnchor),
                        contentView.trailingAnchor.constraint(equalTo: content.trailingAnchor),
                        contentView.topAnchor.constraint(equalTo: content.topAnchor),
                        contentView.bottomAnchor.constraint(equalTo: content.bottomAnchor),
                    ])
                }
    
                func scrollViewDidScroll(_ scrollView: UIScrollView) {
                    let new = scrollView.contentOffset
    
                    // I have to schedule this update for later because:
                    //
                    // 1. updateUIViewController sets scrollView.contentOffset during the SwiftUI update phase.
                    // 2. Setting scrollView.contentOffset makes scrollView call this function.
                    // 3. This function sets contentOffset.wrappedValue.
                    // 4. It's not legal to set the value of a Binding during the SwiftUI update phase.
    
                    guard new != contentOffset.wrappedValue else { return }
                    RunLoop.main.perform(inModes: [.common]) { [contentOffset] in
                        contentOffset.wrappedValue = new
                    }
                }
    
                required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
            }
        }
    }
    

    And here’s the code that I wrapped around it to draw the animated GIF above:

    struct ContentView: View {
        @State private var isList1Visible = true
        @State private var contentOffset = CGPoint.zero
    
        var body: some View {
            ScrollView {
                VStack {
                    Image(systemName: "person.crop.circle.fill")
                        .resizable()
                        .frame(width: 100, height: 100)
    
                    Button(isList1Visible ? "Show List 2" : "Show List 1")  {
                        withAnimation(.easeOut) {
                            isList1Visible.toggle()
                        }
                    }
    
                    filler
    
                    MyScrollView(contentOffset: $contentOffset) {
                        HStack {
                            ForEach(0..<10) { index in
                                Text("(index)")
                                    .frame(width: 100, height: 100)
                                    .background(Color.blue)
                                    .cornerRadius(10)
                                    .id(index)
                            }
                        }
                        .padding(.horizontal, 10)
                    }
                    .frame(height: 150)
                    .frame(height: isList1Visible ? nil : 0, alignment: .top)
                    .clipped()
    
                    Text("Aliquam eget semper ipsum, quis finibus quam. Nulla facilisi. Praesent rutrum sapien eu tortor commodo fringilla.")
                        .padding()
    
                    MyScrollView(contentOffset: $contentOffset) {
                        HStack {
                            ForEach(0..<10) { index in
                                Text("(index)")
                                    .frame(width: 100, height: 100)
                                    .background(Color.green)
                                    .cornerRadius(10)
                                    .id(index)
                            }
                        }
                        .padding(.horizontal, 10)
                    }
                    .frame(height: 150)
                    .frame(height: isList1Visible ? 0 : nil, alignment: .top)
                    .clipped()
    
                    filler; filler; filler; filler
                }
            }
        }
    
        var filler: some View {
            Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed in velit magna. Donec laoreet felis sapien, vel pulvinar orci elementum quis.")
                .padding()
        }
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search