Skip to content

Instantly share code, notes, and snippets.

@mecid
Last active August 15, 2024 06:38
Show Gist options
  • Save mecid/e0d4d6652ccc8b5737449a01ee8cbc6f to your computer and use it in GitHub Desktop.
Save mecid/e0d4d6652ccc8b5737449a01ee8cbc6f to your computer and use it in GitHub Desktop.
PagerView in SwiftUI
//
// PagerView.swift
//
// Created by Majid Jabrayilov on 12/5/19.
// Copyright © 2019 Majid Jabrayilov. All rights reserved.
//
import SwiftUI
struct PagerView<Content: View>: View {
let pageCount: Int
@Binding var currentIndex: Int
let content: Content
@GestureState private var translation: CGFloat = 0
init(pageCount: Int, currentIndex: Binding<Int>, @ViewBuilder content: () -> Content) {
self.pageCount = pageCount
self._currentIndex = currentIndex
self.content = content()
}
var body: some View {
GeometryReader { geometry in
HStack(spacing: 0) {
self.content.frame(width: geometry.size.width)
}
.frame(width: geometry.size.width, alignment: .leading)
.offset(x: -CGFloat(self.currentIndex) * geometry.size.width)
.offset(x: self.translation)
.animation(.interactiveSpring())
.gesture(
DragGesture().updating(self.$translation) { value, state, _ in
state = value.translation.width
}.onEnded { value in
let offset = value.translation.width / geometry.size.width
let newIndex = (CGFloat(self.currentIndex) - offset).rounded()
self.currentIndex = min(max(Int(newIndex), 0), self.pageCount - 1)
}
)
}
}
}
@mecid
Copy link
Author

mecid commented Jan 20, 2020

@mecid How would you solve animation on the pages? now when I have a animation set to a page the whole thing starts animating when I drag

I'm not sure I understand the problem, could you please describe it?

@Plnda
Copy link

Plnda commented Jan 20, 2020

@mecid
Copy link
Author

mecid commented Jan 20, 2020

@majid Here is a sample + relevant code https://gist.github.com/Plnda/121b5960807074cf5fd68c68f959aa54

Interesting issue, you can add animation(nil) modifier to the content inside PagerView, I think it should help.

@Rep0se
Copy link

Rep0se commented Jan 22, 2020

@mecid A bug I noticed (which I've seen in some other apps) when you are switching between vertical scroll views diagonally, all objects will shift horizontally and remain that way until you scroll horizontally again to the next page. Is there a way to disable horizontal scrolling while you are scrolling vertically?
Also, what would change scroll sensitivity?
Edit: found another bug, where an image does not update until the end of the transition.

@fnazarios
Copy link

I've added a Page Control at bottom.

import SwiftUI

struct PagerView<Content: View>: View {
    let pageCount: Int
    @Binding var currentIndex: Int
    let content: Content

    @GestureState private var translation: CGFloat = 0

    init(pageCount: Int, currentIndex: Binding<Int>, @ViewBuilder content: () -> Content) {
        self.pageCount = pageCount
        self._currentIndex = currentIndex
        self.content = content()
    }

    var body: some View {
        GeometryReader { geometry in
            ZStack {
                HStack(spacing: 0) {
                    self.content.frame(width: geometry.size.width)
                }
                .frame(width: geometry.size.width, alignment: .leading)
                .offset(x: -CGFloat(self.currentIndex) * geometry.size.width)
                .offset(x: self.translation)
                .animation(.interactiveSpring())
                .gesture(
                    DragGesture().updating(self.$translation) { value, state, _ in
                        state = value.translation.width
                    }.onEnded { value in
                        let offset = value.translation.width / geometry.size.width
                        let newIndex = (CGFloat(self.currentIndex) - offset).rounded()
                        self.currentIndex = min(max(Int(newIndex), 0), self.pageCount - 1)
                    }
                )
                
                VStack {
                    Spacer()
                    
                    HStack {
                        ForEach(0..<self.pageCount, id: \.self) { index in
                            Circle()
                                .fill(index == self.currentIndex ? Color.white : Color.gray)
                                .frame(width: 10, height: 10)
                        }
                    }
                }
                .offset(y: 16)
            }
        }
    }
}

@mecid
Copy link
Author

mecid commented Jun 3, 2020

@fnazarios that is great! Thanks 🙏🏻

@mmosimann
Copy link

@majid Here is a sample + relevant code https://gist.github.com/Plnda/121b5960807074cf5fd68c68f959aa54

Interesting issue, you can add animation(nil) modifier to the content inside PagerView, I think it should help.

Did you guys found a solution for this problem? I still facing the problem. :(

@mecid
Copy link
Author

mecid commented Jul 20, 2020

@mmosimann disabling animation with .animation(nil) or applying it by withAnimation should solve the issue.

@d03090
Copy link

d03090 commented Dec 13, 2020

Hello! I have tried to make the animation a bit smoother and also tried to use predictedEndTranslation for faster swiping through. I had to change the currentIndex from Int to Float for it to work. Now I'm struggling to get the Binding back to Int, because an Int Index would definitely make more sense.
I would be glad, if you would tell me what you think about the changes and do you know how I can change the Binding back to an Int? Is this possible with Custom Bindings or something like that?

import SwiftUI

struct PagerView<Content: View>: View {
    let pageCount: Int
    @Binding var currentIndex: Int
    @State var currentFloatIndex: CGFloat = 0
    let content: Content

    @GestureState private var offsetX: CGFloat = 0

    init(pageCount: Int, currentIndex: Binding<Int>, @ViewBuilder content: () -> Content) {
        self.pageCount = pageCount
        self._currentIndex = currentIndex
        self.content = content()
    }

    var body: some View {
        GeometryReader { geometry in
            HStack(spacing: 0) {
                self.content.frame(width: geometry.size.width)
            }
            .frame(width: geometry.size.width, alignment: .leading)
            .offset(x: -CGFloat(self.currentFloatIndex) * geometry.size.width)
            .offset(x: self.offsetX)
            .highPriorityGesture(
                DragGesture().updating(self.$offsetX) { value, state, _ in
                    state = value.translation.width
                }
                .onEnded({ (value) in
                    let offset = value.translation.width / geometry.size.width
                    let offsetPredicted = value.predictedEndTranslation.width / geometry.size.width
                    let newIndex = CGFloat(self.currentFloatIndex) - offset
                    
                    self.currentFloatIndex = newIndex
                    
                    withAnimation(.easeOut) {
                        if(offsetPredicted < -0.5 && offset > -0.5) {
                            self.currentFloatIndex = CGFloat(min(max(Int(newIndex.rounded() + 1), 0), self.pageCount - 1))
                        } else if (offsetPredicted > 0.5 && offset < 0.5) {
                            self.currentFloatIndex = CGFloat(min(max(Int(newIndex.rounded() - 1), 0), self.pageCount - 1))
                        } else {
                            self.currentFloatIndex = CGFloat(min(max(Int(newIndex.rounded()), 0), self.pageCount - 1))
                        }
                    }
                })
            )
        }
    }
}

@Degtyarserg
Copy link

Degtyarserg commented Dec 23, 2020

@d03090 The animation looks very nice. Have you found a solution for Binding?

@d03090
Copy link

d03090 commented Dec 23, 2020

@Degtyarserg

Thanks. I just have a VERY UGLY solution and I would be very happy, if someone can do it better.

import SwiftUI

struct PagerView<Content: View>: View {
    let pageCount: Int
    @State var ignore: Bool = false
    @Binding var currentIndex: Int {
        didSet {
            if (!ignore) {
                currentFloatIndex = CGFloat(currentIndex)
            }
        }
    }
    @State var currentFloatIndex: CGFloat = 0 {
        didSet {
            ignore = true
            currentIndex = min(max(Int(currentFloatIndex.rounded()), 0), self.pageCount - 1)
            ignore = false
        }
    }
    let content: Content

    @GestureState private var offsetX: CGFloat = 0

    init(pageCount: Int, currentIndex: Binding<Int>, @ViewBuilder content: () -> Content) {
        self.pageCount = pageCount
        self._currentIndex = currentIndex
        self.content = content()
    }

    var body: some View {
        GeometryReader { geometry in
            HStack(spacing: 0) {
                self.content.frame(width: geometry.size.width)
            }
            .frame(width: geometry.size.width, alignment: .leading)
            .offset(x: -CGFloat(self.currentFloatIndex) * geometry.size.width)
            .offset(x: self.offsetX)
            .highPriorityGesture(
                DragGesture().updating(self.$offsetX) { value, state, _ in
                    state = value.translation.width
                }
                .onEnded({ (value) in
                    let offset = value.translation.width / geometry.size.width
                    let offsetPredicted = value.predictedEndTranslation.width / geometry.size.width
                    let newIndex = CGFloat(self.currentFloatIndex) - offset
                    
                    self.currentFloatIndex = newIndex
                    
                    withAnimation(.easeOut) {
                        if(offsetPredicted < -0.5 && offset > -0.5) {
                            self.currentFloatIndex = CGFloat(min(max(Int(newIndex.rounded() + 1), 0), self.pageCount - 1))
                        } else if (offsetPredicted > 0.5 && offset < 0.5) {
                            self.currentFloatIndex = CGFloat(min(max(Int(newIndex.rounded() - 1), 0), self.pageCount - 1))
                        } else {
                            self.currentFloatIndex = CGFloat(min(max(Int(newIndex.rounded()), 0), self.pageCount - 1))
                        }
                    }
                })
            )
        }
        .onChange(of: currentIndex, perform: { value in
            print("index changed")
            
            // this is probably animated twice, if the tab change occurs because of the drag gesture
            withAnimation(.easeOut) {
                currentFloatIndex = CGFloat(value)
            }
        })
    }
}

@patchthecode
Copy link

@d03090 might be ugly, but it works

@gesabo
Copy link

gesabo commented Mar 8, 2021

@d03090 I tried using your solution and it works well and is smooth, however when I embed it in a scrollview the scroll disabled or broken. Any idea how to enable scrolling?

@wtpalexander
Copy link

Have made my own adjustment to the original to get drag gestures working just like the original PageTabViewStyle. Also uses the predicted swipe end that @d03090 implements and also limits the scroll to one page at a time. FYI @gesabo - This implementation works with a nested ScrollView. Tested on Xcode 12.4, iOS 14.4. Thank you @mecid for the original code!
https://gist.github.com/wtpalexander/565ccf7bb092734dbf2c584dd639fb46

@gesabo
Copy link

gesabo commented Mar 10, 2021

@wtpalexander thanks for that, unfortunately it still doesn't solve my UI. My view hierarchy is like this: Master View that has the PagerView as a Child, and the PagerView has 3 subviews that need to be able to be scrolled vertically. Ideally I would like the Master View to have a ScrollView so that when the subviews content is scrolled, the Large Title in the Navigation Bar become small. The problem is when I add a ScrollView to Master View, it seems as though PagerView prevents the vertical scrolling and won't let me fully scroll.

@wtpalexander
Copy link

@gesabo Apologies - I miss understood your requirements! So you'd like a scroll view that snaps to each page, but when scrolled to the second or third nested view, the NavBar changes from LargeTitle to Inline?

@gesabo
Copy link

gesabo commented Mar 11, 2021

@wtpalexander yes it seems that if the PagerView is embedded in a ScrollView then the sub views of the PagerView are not scrollable. Again my view hierarchy is Master View -> PagerView -> Subviews. For now I have the subviews having ScrollViews, which works to scroll vertically to see their content, however the large title the nav bar doesn't change to inline. Ideally I would like to remove the ScrollView from the subviews and place it in the MasterView so I can get this functionality where the large title changes to inline.

@shaps80
Copy link

shaps80 commented Jun 28, 2021

@mecid / @d03090 / @gesabo
Just my two cents, as often as possible I prefer to use the real-thing given Apple usually does a far more complete implementation for other things like accessibility. In addition, the animations and gesture implementations are more consistent across iOS versions. In that vein, I've created an implementation that just wraps TabView and provides auto-sizing based on the content (height).

Since it wraps TabView the API is identical and it behaves exactly as expected. Feel free to grab it here if it works for your needs:
https://gist.github.com/shaps80/65d9d58480469bfe8eff6542f1dd16c4

@wtpalexander I tried your solution but I found the gestures to be very 'sticky' and difficult to work with, maybe this works for you too? 👍

@cecipirotto
Copy link

My team recently delivered PagerTabStripView, a new Open Source for Pager Tab views. This is the GitHub repo. You can read an introduction in this post.
Here is an example gif!
enter image description here

We'll appreciate it if you can take a look! If you like the library, want to contribute to the project, or need some help using it, please reach out to us, open an issue or a pull request.

@sinnuswong
Copy link

sinnuswong commented Apr 19, 2022

This will more smoothly

import Foundation
import SwiftUI

struct PagerView<Content: View>: View {
    let pageCount: Int
    @State var ignore: Bool = false
    @Binding var currentIndex: Int {
        didSet {
            if (!ignore) {
                currentFloatIndex = CGFloat(currentIndex)
            }
        }
    }
    @State var currentFloatIndex: CGFloat = 0 {
        didSet {
            ignore = true
            currentIndex = min(max(Int(currentFloatIndex.rounded()), 0), self.pageCount - 1)
            ignore = false
        }
    }
    let content: Content

    @GestureState private var offsetX: CGFloat = 0

    init(pageCount: Int, currentIndex: Binding<Int>, @ViewBuilder content: () -> Content) {
        self.pageCount = pageCount
        self._currentIndex = currentIndex
        self.content = content()
    }

    var body: some View {
        GeometryReader { geometry in
            HStack(spacing: 0) {
                self.content.frame(width: geometry.size.width)
            }
            .frame(width: geometry.size.width, alignment: .leading)
            .offset(x: -CGFloat(self.currentFloatIndex) * geometry.size.width)
            .offset(x: self.offsetX)
            .animation(.linear, value:offsetX)
            .highPriorityGesture(
                DragGesture().updating(self.$offsetX) { value, state, _ in
                    state = value.translation.width
                }
                .onEnded({ (value) in
                    let offset = value.translation.width / geometry.size.width
                    let offsetPredicted = value.predictedEndTranslation.width / geometry.size.width
                    let newIndex = CGFloat(self.currentFloatIndex) - offset
                    
                    self.currentFloatIndex = newIndex
                    
                    withAnimation(.easeOut) {
                        if(offsetPredicted < -0.5 && offset > -0.5) {
                            self.currentFloatIndex = CGFloat(min(max(Int(newIndex.rounded() + 1), 0), self.pageCount - 1))
                        } else if (offsetPredicted > 0.5 && offset < 0.5) {
                            self.currentFloatIndex = CGFloat(min(max(Int(newIndex.rounded() - 1), 0), self.pageCount - 1))
                        } else {
                            self.currentFloatIndex = CGFloat(min(max(Int(newIndex.rounded()), 0), self.pageCount - 1))
                        }
                    }
                })
            )
        }
        .onChange(of: currentIndex, perform: { value in
            print("index changed")
            
            // this is probably animated twice, if the tab change occurs because of the drag gesture
            withAnimation(.easeOut) {
                currentFloatIndex = CGFloat(value)
            }
        })
    }
}

@samilao101
Copy link

Is there any way to load the views lazily?

@mecid
Copy link
Author

mecid commented May 5, 2022

@samilao101 did you try to use LazyHStack instead of HStack?

@samilao101
Copy link

@mecid I did, but the issue is that in order for a lazyhstack to become lazy it needs to be imbedded in a scrollview, and if you do that, you essentially lose the pagination feature.

@mecid
Copy link
Author

mecid commented May 5, 2022

@samilao101 oh, you're right. Maybe TabView with Page style will work for you better?

@pjcau
Copy link

pjcau commented May 30, 2022

So isn't the lazy this approach....Load all contents and onAppear

@samilao101
Copy link

@pjcau sorry I am not sure I understand what you mean

@pjcau
Copy link

pjcau commented May 30, 2022

@pjcau sorry I am not sure I understand what you mean

IN this way, if you want load a array of DetalView for example, so load in one step all init and onAppear(). So it's not good. But fix it with TabView with Page style.

Copy link

ghost commented Sep 27, 2022

@pjcau did you manage to integrate ScrollView in order to achieve lazy loading?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment