26

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!

HelloTimo
  • 558
  • 1
  • 5
  • 18

6 Answers6

23

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.

marc_s
  • 732,580
  • 175
  • 1,330
  • 1,459
gujci
  • 1,238
  • 13
  • 21
  • Thanks a lot. Looks promising. I can test it in a few days earliest. – HelloTimo Oct 10 '19 at 13:23
  • wow.. it worked for me. I tried to do the same using UIKit, but don't know how to set contentOffset of scrollView, which is unattainable from the wrapper.. Thank you! – Hrabovskyi Oleksandr Nov 06 '19 at 16:33
  • This and every solution I have come across has each element in the list as a single page in the scrollview that fills the width of the screen. I NEED to give a visual cue to the user that there are more items to pick from by SHOWING THEM, just like a scrollview in UIKit. This is really great, but does not embrace the spirit of a UIKit scrollview with non device width pages. – Moebius Jun 19 '20 at 12:09
5

@gujci your solution is perfect, for more general usage, make it accept Models and view builder as in (note the I pass the geometry size in the builder) :

struct SwiftUIPagerView<TModel: Identifiable ,TView: View >: View {

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

    // 1
    var pages: [TModel]
    var builder : (CGSize, TModel) -> TView

    var body: some View {
        GeometryReader { geometry in
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(alignment: .center, spacing: 0) {
                    ForEach(self.pages) { page in
                        self.builder(geometry.size, page)
                    }
                }
            }
            // 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 }
            }))
        }
    }
}

and can be used as :

    struct WrapperView: View {

        @State var index: Int = 0
        @State var items : [(color:Color,name:String)] = [
            (.red,"Red"),
            (.green,"Green"),
            (.yellow,"Yellow"),
            (.blue,"Blue")
        ]
        var body: some View {
            VStack(spacing: 0) {

                SwiftUIPagerView(index: $index, pages: self.items.identify { $0.name }) { size, item in
                    TODOView(extraInfo: item.model.name)
                        .frame(width: size.width, height: size.height)
                        .background(item.model.color)
                }

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

            }.edgesIgnoringSafeArea(.all)
        }
    }

with the help of some utilities :

    struct MakeIdentifiable<TModel,TID:Hashable> :  Identifiable {
        var id : TID {
            return idetifier(model)
        }
        let model : TModel
        let idetifier : (TModel) -> TID
    }
    extension Array {
        func identify<TID: Hashable>(by: @escaping (Element)->TID) -> [MakeIdentifiable<Element, TID>]
        {
            return self.map { MakeIdentifiable.init(model: $0, idetifier: by) }
        }
    }
Ala'a Al Hallaq
  • 455
  • 5
  • 11
2

@gujci, thank you for interesting example. I've played with it and removed the isGestureActive state. Full example may be found in my gist.

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

    @State private var index: Int = 0
    @State private var offset: CGFloat = 0

    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)
                    }
                }
            }
            .content.offset(x: self.offset)
            .frame(width: geometry.size.width, height: nil, alignment: .leading)
            .gesture(DragGesture()
                .onChanged({ value in
                    self.offset = value.translation.width - geometry.size.width * CGFloat(self.index)
                })
                .onEnded({ value in
                    if abs(value.predictedEndTranslation.width) >= geometry.size.width / 2 {
                        var nextIndex: Int = (value.predictedEndTranslation.width < 0) ? 1 : -1
                        nextIndex += self.index
                        self.index = nextIndex.keepIndexInRange(min: 0, max: self.pages.endIndex - 1)
                    }
                    withAnimation { self.offset = -geometry.size.width * CGFloat(self.index) }
                })
            )
        }
    }
}
Alex.O
  • 21
  • 2
2

Details

  • Xcode 14
  • Swift 5.6.1

Requirements

  1. I do not want to use integration with UIKit (clean SwiftUI ONLY)
  2. I do not want to scroll to the any "ID", I want to scroll to the point

Solution

import SwiftUI


@available(iOS 14.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
struct ExtendedScrollView<Content>: View where Content: View {

    private let contentProvider: _AligningContentProvider<Content>
    // Main Idea from: https://github.com/edudnyk/SolidScroll/blob/main/Sources/SolidScroll/ScrollView.swift

    private var config: _ScrollViewConfig

    init(config: _ScrollViewConfig = _ScrollViewConfig(),
         @ViewBuilder content: () -> Content) {
        contentProvider = _AligningContentProvider(content: content(), horizontal: .center, vertical: .center)
        self.config = config
    }

    init(_ axes: Axis.Set = .vertical,
         showsIndicators: Bool = true,
         @ViewBuilder content: () -> Content) {
        var config = _ScrollViewConfig()
        config.showsHorizontalIndicator = axes.contains(.horizontal) && showsIndicators
        config.showsVerticalIndicator = axes.contains(.vertical) && showsIndicators
        self.init(config: config, content: content)
    }

    init(config: () -> _ScrollViewConfig,
         @ViewBuilder content: () -> Content) {
        self.init(config: config(), content: content)
    }

    var body: some View {
        _ScrollView(contentProvider: contentProvider, config: config)
    }
}

extension _ContainedScrollViewKey: PreferenceKey {}

// MARK: Track ScrollView Scrolling

struct TrackableExtendedScrollView: ViewModifier {

    let onChange: (_ScrollViewProxy?) -> Void
    func body(content: Content) -> some View {
        content
            .onPreferenceChange(_ContainedScrollViewKey.self, perform: onChange)
    }
}

extension View {
    func onScrollChange(perform: @escaping (_ScrollViewProxy?) -> Void) -> some View {
        modifier(TrackableExtendedScrollView(onChange: perform))
    }
}

Usage Sample

private var gridItemLayout = (0..<40).map { _ in
      GridItem(.fixed(50), spacing: 0, alignment: .leading)
}

// ....

ExtendedScrollView() {
    LazyHGrid(rows: gridItemLayout) {
        ForEach((0..<numberOfRows*numberOfColumns), id: \.self) { index in
            let color = (index/numberOfRows)%2 == 0 ? Color(0x94D2BD) : Color(0xE9D8A6)
            Text("\(index)")
                .frame(width: 50)
                .frame(maxHeight: .infinity)
        }
    }
}
.onScrollChange { proxy in
   // let offset = proxy?.contentOffset.y
}

Full Sample

Implementation details

  • First column and first row are always on the screen
  • There are 3 "CollectionView":
    1. first row "CollectionView"
    2. first column "CollectionView"
    3. main content "CollectionView"
  • All "CollectionView" are synced (if you scroll one "CollectionView", another will also be scrolled)

Do not forget to paste The Solution code here

import SwiftUI
import Combine

struct ContentView: View {
    private let columWidth: CGFloat = 50
    private var gridItemLayout0 = [GridItem(.fixed(50), spacing: 0, alignment: .leading)]
    private var gridItemLayout1 = [GridItem(.fixed(50), spacing: 0, alignment: .leading)]

    private var gridItemLayout = (0..<40).map { _ in
        GridItem(.fixed(50), spacing: 0, alignment: .leading)
    }

    @State var text: String = "scrolling not detected"
    @State private var scrollViewProxy1: _ScrollViewProxy?
    @State private var tableContentScrollViewProxy: _ScrollViewProxy?
    @State private var tableHeaderScrollViewProxy: _ScrollViewProxy?

    private let numberOfColumns = 50
    private let numberOfRows = 40

    let headerColor = Color(0xEE9B00)
    let firstColumnColor = Color(0x0A9396)
    let headerTextColor = Color(.white)

    let horizontalSpacing: CGFloat = 6
    let verticalSpacing: CGFloat = 0
    let firstColumnWidth: CGFloat = 100
    let columnWidth: CGFloat = 60

    var body: some View {
        VStack(spacing: 0)  {
            Text("First column and row are sticked to the content")
                .foregroundColor(.gray)
            Text(text)
            HStack {
                Rectangle()
                    .frame(width: firstColumnWidth-2)
                    .foregroundColor(.clear)
                buildFirstCollectionViewRow()
            }
            .frame(height: 50)


            HStack(alignment: .firstTextBaseline, spacing: horizontalSpacing) {
                buildFirstCollectionViewColumn()
                buildCollectionViewContent()
            }
        }
    }

    @ViewBuilder
    private func buildFirstCollectionViewRow() -> some View {
        ExtendedScrollView() {

            LazyHGrid(rows: gridItemLayout1, spacing: horizontalSpacing) {
                ForEach((0..<numberOfColumns), id: \.self) {
                    let color = $0%2 == 0 ? Color(0x005F73) : Color(0xCA6702)
                    Text("Value\($0)")
                        .frame(width: columnWidth)
                        .frame(maxHeight: .infinity)
                        .foregroundColor(headerTextColor)
                        .background(color)
                        .font(.system(size: 16, weight: .semibold))
                }
            }
        }
        .onScrollChange { proxy in
            if tableHeaderScrollViewProxy != proxy { tableHeaderScrollViewProxy = proxy }
            guard proxy?.isScrolling ?? false else { return }
            if tableHeaderScrollViewProxy?.contentOffset.x != tableContentScrollViewProxy?.contentOffset.x,
               let offset = proxy?.contentOffset.x {
                tableContentScrollViewProxy?.contentOffset.x = offset
            }

            text = "scrolling: header"
        }
    }
}

// MARK: Collection View Elements

extension ContentView {

    @ViewBuilder
    private func buildFirstCollectionViewColumn() -> some View {
        ExtendedScrollView() {
            LazyHGrid(rows: gridItemLayout, spacing: horizontalSpacing) {
                ForEach((0..<numberOfRows), id: \.self) {
                    Text("multi line text \($0)")
                        .foregroundColor(.white)
                        .lineLimit(2)
                        .frame(width: firstColumnWidth)
                        .font(.system(size: 16, weight: .semibold))
                        .frame(maxHeight: .infinity)
                        .background(firstColumnColor)
                        .border(.white)
                }
            }
        }
        .frame(width: firstColumnWidth)
        .onScrollChange { proxy in
            if scrollViewProxy1 != proxy { scrollViewProxy1 = proxy }
            guard proxy?.isScrolling ?? false else { return }
            if scrollViewProxy1?.contentOffset.y != tableContentScrollViewProxy?.contentOffset.y,
               let offset = proxy?.contentOffset.y {
                tableContentScrollViewProxy?.contentOffset.y = offset
            }
            text = "scrolling: 1st column"
        }
    }

    @ViewBuilder
    private func buildCollectionViewContent() -> some View {
        ExtendedScrollView() {
            LazyHGrid(rows: gridItemLayout, spacing: horizontalSpacing) {
                ForEach((0..<numberOfRows*numberOfColumns), id: \.self) { index in
                    let color = (index/numberOfRows)%2 == 0 ? Color(0x94D2BD) : Color(0xE9D8A6)
                    Text("\(index)")
                        .frame(width: columnWidth)
                        .frame(maxHeight: .infinity)
                        .background(color)
                        .border(.white)
                }
            }
        }
        .onScrollChange { proxy in
            if tableContentScrollViewProxy != proxy { tableContentScrollViewProxy = proxy }
            guard proxy?.isScrolling ?? false else { return }
            if scrollViewProxy1?.contentOffset.y != tableContentScrollViewProxy?.contentOffset.y,
               let offset = proxy?.contentOffset.y {
                self.scrollViewProxy1?.contentOffset.y = offset
            }

            if tableHeaderScrollViewProxy?.contentOffset.x != tableContentScrollViewProxy?.contentOffset.x,
               let offset = proxy?.contentOffset.x {
                self.tableHeaderScrollViewProxy?.contentOffset.x = offset
            }
            text = "scrolling: content"
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

extension Color {
    init(_ hex: UInt, alpha: Double = 1) {
        self.init(
            .sRGB,
            red: Double((hex >> 16) & 0xFF) / 255,
            green: Double((hex >> 8) & 0xFF) / 255,
            blue: Double(hex & 0xFF) / 255,
            opacity: alpha
        )
    }
}

Full Sample Demo

enter image description here

Vasily Bodnarchuk
  • 24,482
  • 9
  • 132
  • 127
1

As far as I know scrolls in swiftUI doesn't support anything potentially useful such as scrollViewDidScroll or scrollViewWillEndDragging yet. I suggest using either classic UIKit views for making very custom behavior and cool SwiftUI views for anything that is easier. I've tried that a lot and it actually works! Have a look at this guide. Hope that helps

glassomoss
  • 736
  • 6
  • 7
0

Alternative solution would be to integrate UIKit into SwiftUI using UIViewRepresentative which links UIKit components with SwiftUI. For additional leads and resources, see how Apple suggests you interface with UIKit: Interfacing with UIKit. They have a good example that shows to page between images and track selection index.

Edit: Until they (Apple) implement some sort of content offset that effects the scroll instead of the entire view, this is their suggested solution since they knew the initial release of SwiftUI wouldn't encompass all functionality of UIKit.

Kyle Beard
  • 604
  • 5
  • 18
  • The Apple example uses a UIPageViewController which makes each view in the scrollview take up the entire width of the screen. The UI that I want to design has the items in the scrollview next to each other so the user knows that he/she can scroll. I went down the UIPageViewController path and ended up wasting 2-3 hours in research. – Moebius Jun 19 '20 at 11:53
  • @Moebius did you ever figure out how to implement this using just a scrollview? – batman May 13 '22 at 11:48