HelloTimo
HelloTimo

Reputation: 578

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

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!

Upvotes: 27

Views: 31329

Answers (5)

Vasily  Bodnarchuk
Vasily Bodnarchuk

Reputation: 25294

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

Upvotes: 2

Kyle Beard
Kyle Beard

Reputation: 664

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.

Upvotes: 0

Ala&#39;a Al Hallaq
Ala&#39;a Al Hallaq

Reputation: 505

@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) }
        }
    }

Upvotes: 6

Alex.O
Alex.O

Reputation: 21

@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) }
                })
            )
        }
    }
}

Upvotes: 2

gujci
gujci

Reputation: 1284

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.

Upvotes: 24

Related Questions