SwiftUI ScrollView: How to modify .content.offset aka Paging?

HelloTimo picture HelloTimo · Jul 14, 2019 · Viewed 11.8k times · Source

Problem

How can I modify the scroll target of a scrollView? I am looking for kind of a replacement for the "classic" scrollView delegate method

override func scrollViewWillEndDragging(scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>)

...where we can modfify the targeted scrollView.contentOffset via targetContentOffset.pointee for instance to create a custom paging behaviour.

Or in other words: I do want to create a paging effect in a (horizontal) scrollView.

What I have tried ie. is something like this:

    ScrollView(.horizontal, showsIndicators: true, content: {
            HStack(alignment: VerticalAlignment.top, spacing: 0, content: {
                card(title: "1")
                card(title: "2")
                card(title: "3")
                card(title: "4")
            })
     })
    // 3.
     .content.offset(x: self.dragState.isDragging == true ? self.originalOffset : self.modifiedOffset, y: 0)
    // 4.
     .animation(self.dragState.isDragging == true ? nil : Animation.spring())
    // 5.
    .gesture(horizontalDragGest)

Attempt

This is what I tried (besides a custom scrollView approach):

  1. A scrollView has a content area larger then screen space to enable scrolling at all.

  2. I created a DragGesture() to detect if there is a drag going on. In the .onChanged and .onEnded closures I modified my @State values to create a desired scrollTarget.

  3. Conditionally fed in both the original unchanged and the new modified values into the .content.offset(x: y:) modifier - depending on the dragState as a replacement for missing scrollDelegate methods.

  4. Added animation acting conditionally only when drag has ended.

  5. Attached the gesture to the scrollView.

Long story short. It doesn't work. I hope I got across what my problem is.

Any solutions out there? Looking forward to any input. Thanks!

Answer

gujci picture gujci · Oct 9, 2019

I have managed to achieve a paging behaviour with a @Binding index. The solution might look dirty, I'll explain my workarounds.

The first thing I got wrong, was to get alignment to .leading instead of the default .center, otherwise the offset works unusual. Then I combined the binding and a local offset state. This kinda goes against the "Single source of truth" principle, but otherwise I had no idea how to handle external index changes and modify my offset.

So, my code is the following

struct SwiftUIPagerView<Content: View & Identifiable>: View {

    @Binding var index: Int
    @State private var offset: CGFloat = 0
    @State private var isGestureActive: Bool = false

    // 1
    var pages: [Content]

    var body: some View {
        GeometryReader { geometry in
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(alignment: .center, spacing: 0) {
                    ForEach(self.pages) { page in
                        page
                            .frame(width: geometry.size.width, height: nil)
                    }
                }
            }
            // 2
            .content.offset(x: self.isGestureActive ? self.offset : -geometry.size.width * CGFloat(self.index))
            // 3
            .frame(width: geometry.size.width, height: nil, alignment: .leading)
            .gesture(DragGesture().onChanged({ value in
                // 4
                self.isGestureActive = true
                // 5
                self.offset = value.translation.width + -geometry.size.width * CGFloat(self.index)
            }).onEnded({ value in
                if -value.predictedEndTranslation.width > geometry.size.width / 2, self.index < self.pages.endIndex - 1 {
                    self.index += 1
                }
                if value.predictedEndTranslation.width > geometry.size.width / 2, self.index > 0 {
                    self.index -= 1
                }
                // 6
                withAnimation { self.offset = -geometry.size.width * CGFloat(self.index) }
                // 7
                DispatchQueue.main.async { self.isGestureActive = false }
            }))
        }
    }
}
  1. you may just wrap your content, I used it for "Tutorial Views".
  2. this a trick to switch between external and internal state changes
  3. .leading is mandatory if you don't want to translate all offsets to center.
  4. set the state to local state change
  5. calculate the full offset from the gesture delta (*-1) plus the previous index state
  6. at the end set the final index based on the gesture predicted end, while rounding the offset up or down
  7. reset the state to handle external changes to index

I have tested it in the following context

    struct WrapperView: View {

        @State var index: Int = 0

        var body: some View {
            VStack {
                SwiftUIPagerView(index: $index, pages: (0..<4).map { index in TODOView(extraInfo: "\(index + 1)") })

                Picker(selection: self.$index.animation(.easeInOut), label: Text("")) {
                    ForEach(0..<4) { page in Text("\(page + 1)").tag(page) }
                }
                .pickerStyle(SegmentedPickerStyle())
                .padding()
            }
        }
    }

where TODOView is my custom view that indicates a view to implement.

I hope I get the question right, if not please specify which part should I focus on. Also I welcome any suggestions to remove the isGestureActive state.