1

I am trying to recreate this view from an app. essentially its two repeating/infinite scrollable views. the top contains the days of the week, and the bottom is a paging view that shows the classes on that day. Interacting with either scrollable view (even slightly) will animate both. both views are infinite/repeating, and the items (text) at the top are variable width. i believe the snapping of the top view is dependent on the paging of the bottom, as you cant free scroll it.

GIF demonstrating desired view

i am aware that the 2 views can be synced using the iOS 17 apis for scroll view, but the scroll only syncs after the scroll is completed. as you can see below, the other scrollview doesn't sync until its been scrolled a certain amount.

GIF demonstrating synced position using scrollPosition

import SwiftUI

struct ContentView: View {
    @State private var scrollPosition1: Int? = 1
    @State private var scrollPosition2: Int? = 1

    var body: some View {
        GeometryReader { geometry in
            VStack {
                Text("Current Index: \(scrollPosition1 ?? 0)")
                    .padding()
                
                // First ScrollView, view aligned
                createScrollView(for: $scrollPosition1, color: .blue, frameWidth: geometry.size.width/3)
                    .scrollTargetBehavior(.viewAligned)
                
                // Second ScrollView, paging
                createScrollView(for: $scrollPosition2, color: .green, frameWidth: geometry.size.width)
                    .scrollTargetBehavior(.paging)
                
                
            }
            // When first one changes, update the second.
            .onChange(of: scrollPosition1) { oldValue, newValue in
                withAnimation() {
                    if scrollPosition2 != newValue {
                        scrollPosition2 = newValue
                    }
                }
            }
            // When second one changes, update the first.
            .onChange(of: scrollPosition2) { oldValue, newValue in
                withAnimation() {
                    if scrollPosition1 != newValue {
                        scrollPosition1 = newValue
                    }
                }
            }
        }
    }
    
    @ViewBuilder
    private func createScrollView(for position: Binding<Int?>, color: Color, frameWidth: CGFloat) -> some View {
        ScrollView(.horizontal) {
            HStack(spacing: 0) {
                ForEach((1...30), id: \.self) { index in
                    Text("\(index)")
                        .font(.title2)
                        .frame(width: frameWidth, height: 70)
                        .background(color.opacity((index == position.wrappedValue) ? 0.4 : 0.2))
                        .cornerRadius(30)
                        .id(index)
                        
                }
            }
            .scrollTargetLayout()
        }
        .scrollIndicators(.hidden)
        // Bind this scroll view to its specific position state
        .scrollPosition(id: position, anchor: .leading)
    }
}

#Preview {
    ContentView()
}

I'm fairly new to swiftUI, but i cant seem to find any way of replicating that view, or examples that contain all the elements. i would really appreciate some help with this. Thank You!

3
  • 1
    It would seem simpler to create a single scroll view Commented Aug 5 at 21:46
  • this SO post/answer may help stackoverflow.com/questions/79634553/… Commented Aug 6 at 0:42
  • Your code works, except for the granularity. When you scroll one of the views, the other one is only updated when the selection actually changes. If you want it to work with point-by-point granularity then you could consider using an HStack with DragGesture and offset instead. For content that cycles (loops), this may be the best approach anyway. Commented Aug 6 at 6:25

1 Answer 1

0

Not accounting for the infinite loop aspect (which is not included in your sample code), here's an example using synced scroll views and a simultaneous drag gesture.

The scroll views are implemented as their own views, rather than another View property of the main views, to allow for better scalability. If you were to add a third scrollview in your sample code, you'd need a third state and even more complex .onChange logic. Instead, in the code below, you have a local state and a shared state - adding a third scroll view doesn't require any extra states.

Here's the full working code to try:

import SwiftUI

struct SyncedScrollingView: View {
    @State private var scrollPosition: Int?

    var body: some View {
        VStack {
            Text("Current Index: \(scrollPosition ?? 0)")
                .padding()
            
            SyncedScrollChildView(sharedPosition: $scrollPosition, color: .blue, fit: 3)
                
            SyncedScrollChildView(sharedPosition: $scrollPosition, color: .green, fit: 1)
            
        }
    }
}

struct SyncedScrollChildView: View {
    
    //Parameters
    @Binding var sharedPosition: Int?
    var color: Color
    var fit: Int
    
    //State values
    @State private var scrollPosition: Int?
    
    //Gesture states
    @GestureState private var dragged = false
    
    //Body
    var body: some View {
        
        let dragGesture = DragGesture()a
            .updating($dragged) { value, dragged, transaction in
                if !dragged {
                    if value.translation.width < -20, let scrollPosition, scrollPosition < 30 {
                        self.sharedPosition = scrollPosition + 1
                        dragged = true
                    }
                    else if value.translation.width > 20, let scrollPosition, scrollPosition > 1 {
                        self.sharedPosition = scrollPosition - 1
                        dragged = true
                    }
                }
            }
            .onEnded { _ in
                sharedPosition = scrollPosition
            }
        
        ScrollView(.horizontal) {
            HStack(spacing: 0) {
                ForEach((1...30), id: \.self) { index in
                    Button {
                        sharedPosition = index
                    } label: {
                        Text("\(index)")
                            .containerRelativeFrame(.horizontal) { dimension, _ in
                                dimension / CGFloat(fit)
                            }
                            .font(.title2)
                            .background(color.opacity((index == scrollPosition) ? 0.4 : 0.2), in: Capsule())
                    }
                    
                }
            }
            .scrollTargetLayout()
        }
        .simultaneousGesture(dragGesture)
        .scrollIndicators(.hidden)
        .scrollTargetBehavior(.viewAligned)
        .scrollPosition(id: $scrollPosition, anchor: .leading)
        .animation(.smooth, value: scrollPosition)
        .onChange(of: sharedPosition) {
            if !dragged {
                scrollPosition = sharedPosition
            }
        }
        .onChange(of: scrollPosition) {
            sharedPosition = scrollPosition
        }
        
    }
}

#Preview {
    SyncedScrollingView()
}

enter image description here

Sign up to request clarification or add additional context in comments.

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.