Search code examples
swiftuiscrollview

How can I have ScrollTargetBehaviour change based off scroll position?


I'm working on an app where I have one or more featured, full screen views presented before the rest of the "explore" content (think home page on Spotify).

I have the following structure at the moment:

GeometryReader { proxy in
        let h = proxy.size.height
        
        ScrollView {
          if data == nil {
            Recommended(event: nil)
              .padding(.vertical, 8)
              .frame(height: h)
              .frame(maxWidth: .infinity)
          } else {
            LazyVStack(spacing: proxy.safeAreaInsets.bottom / 2) {
              ForEach(data?.recommended ?? []) { _ in
                Recommended(event: events[0])
                  .padding(.vertical, 8)
                  .frame(height: h)
                  .frame(maxWidth: .infinity)
                  .padding(.bottom, proxy.safeAreaInsets.bottom / 2)
              }
            }
            .scrollTargetLayout()
          }
          
          if let sections = data?.sections {
            LazyVStack(spacing: 32) {
              ForEach(Array(sections.enumerated()), id: \.offset) { e in
                let section = e.element
                  
                EventRow(title: section.title, events: section.events)
              }
            }
            .scrollTargetLayout()
          }
          
          Spacer()
        }
    //    .scrollPosition(id: $scrollID) this was an attempt
    //    .scrollTargetBehavior(.custom) this was another attempt
        .scrollDisabled(data == nil)
        .scrollIndicators(.hidden)
        .ignoresSafeArea(.container)
        .refreshable {
          print("refresh")
        }
        .onChange(of: scrollID) { _, newValue in
          print(newValue ?? "")
        }
      }

I attempted to switch the paging style based off the scrollID but got the following after writing a ternary

.scrollTargetBehavior(true ? .paging : .viewAligned)

// Member 'viewAligned' in 'PagingScrollTargetBehavior' produces result of type 'ViewAlignedScrollTargetBehavior', but context expects 'PagingScrollTargetBehavior'

I was also looking into a custom implementation of scroll target behavior but my experimentation yielded few results.

Minimal Reproducible Example

struct ContentView: View {
  let colors: [Color] = [.blue, .green, .yellow, .red]

  var body: some View {
    GeometryReader { proxy in
      ScrollView(.vertical) {
        LazyVStack(spacing: proxy.safeAreaInsets.bottom / 2) {
          ForEach(0 ..< 2) { i in
            ZStack {
              Rectangle()
                .fill(colors[i % colors.count].opacity(0.6))
                .containerRelativeFrame([.horizontal, .vertical])
                .padding(.bottom, proxy.safeAreaInsets.bottom / 2)
                .frame(
                  width: proxy.size.width,
                  height: proxy.size.height + (proxy.safeAreaInsets.bottom / 2)
                )
              Text("Video \(i + 1)")
                .font(.title)
                .bold()
            }
          }
        }
        .scrollTargetLayout()
        
        // these elements should scroll normally without snapping
        ForEach(0 ..< 20) { i in
          Rectangle()
            .fill(.purple)
            .frame(width: 100, height: 100)
            .overlay {
              Text("\(i)")
            }
        }
      }
      .scrollTargetBehavior(.paging)
      .ignoresSafeArea(edges: .bottom)
    }
  }
}

#Preview {
  ContentView()
}

Solution

  • You can write your own ScrollTargetBehaviour. Its updateTarget will check where the scroll view would naturally scroll to, and if it is still in the "paging area", delegate to PagingScrollTargetBehavior. Otherwise it would do nothing, i.e. scroll naturally.

    struct PartlyPagingBehaviour: ScrollTargetBehavior {
        let pageCount: Int
        func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
            let targetDimension = context.axes == .vertical ? target.rect.maxY : target.rect.maxX
            let containerDimension = context.axes == .vertical ? context.containerSize.height : context.containerSize.width
            if targetDimension < containerDimension * CGFloat(pageCount) {
                PagingScrollTargetBehavior.paging.updateTarget(&target, context: context)
            }
        }
    }
    

    This implementation uses the natural behaviour if any part of the non-paging area is visible, by checking target.rect.maxY. You can check target.rect.minY instead, if you want the paging area to be entirely invisible before the natural behaviour starts.

    For the example in the MRE, you can use

    .scrollTargetBehavior(PartlyPagingBehaviour(pageCount: 2))
    

    That said, the snapping to each page is a bit slower than the actual .paging behaviour. I assume PagingScrollTargetBehavior is a special case that SwiftUI checks for, and handles it a bit differently.