Someone helped with the below code to achieve what I needed to do here:-
Whilst keeping the existing behaviour intact, I would like some assistance please to modify probably just the scrollViewDidScroll method to allow for an infinite smooth scrolling so when you reach the fourth / last item as you swipe right it will smoothly transition to the first page and likewise if you keep swiping back to the left as you reach the first and swipe left again the last item will be displayed, if you know what I mean, I tried doing it but was playing up a bit. Thank you
class PagedScrollViewController: UIViewController, UIScrollViewDelegate {
let scrollView: UIScrollView = {
let v = UIScrollView()
v.isPagingEnabled = true
v.bounces = false
return v
}()
let pageControl: UIPageControl = {
let v = UIPageControl()
return v
}()
let stack: UIStackView = {
let v = UIStackView()
v.axis = .horizontal
v.distribution = .fillEqually
return v
}()
var pages: [UIViewController] = []
override func viewDidLoad() {
super.viewDidLoad()
scrollView.translatesAutoresizingMaskIntoConstraints = false
pageControl.translatesAutoresizingMaskIntoConstraints = false
stack.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(stack)
view.addSubview(scrollView)
view.addSubview(pageControl)
let g = view.safeAreaLayoutGuide
let svCLG = scrollView.contentLayoutGuide
let svFLG = scrollView.frameLayoutGuide
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
scrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -80.0),
stack.topAnchor.constraint(equalTo: svCLG.topAnchor, constant: 0.0),
stack.leadingAnchor.constraint(equalTo: svCLG.leadingAnchor, constant: 0.0),
stack.trailingAnchor.constraint(equalTo: svCLG.trailingAnchor, constant: 0.0),
stack.bottomAnchor.constraint(equalTo: svCLG.bottomAnchor, constant: 0.0),
stack.heightAnchor.constraint(equalTo: svFLG.heightAnchor, constant: 0.0),
pageControl.topAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: 8.0),
pageControl.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
pageControl.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
])
// if we're loading "page" view controllers from Storyboard
/*
if let vc = storyboard?.instantiateViewController(withIdentifier: "psFirst") as? PSFirstViewController {
pages.append(vc)
}
if let vc = storyboard?.instantiateViewController(withIdentifier: "psSecond") as? PSSecondViewController {
pages.append(vc)
}
if let vc = storyboard?.instantiateViewController(withIdentifier: "psThird") as? PSThirdViewController {
pages.append(vc)
}
if let vc = storyboard?.instantiateViewController(withIdentifier: "psFourth") as? PSFourthViewController {
pages.append(vc)
}
pages.forEach { vc in
self.addChild(vc)
stack.addArrangedSubview(vc.view)
vc.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: 0.0).isActive = true
vc.didMove(toParent: self)
}
*/
// for this example,
// create 4 view controllers, with background colors
let colors: [UIColor] = [
.red, .brown, .blue, .magenta
]
colors.forEach { c in
let vc = BasePageController()
vc.view.backgroundColor = c
self.addChild(vc)
stack.addArrangedSubview(vc.view)
vc.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: 0.0).isActive = true
vc.didMove(toParent: self)
pages.append(vc)
}
pageControl.numberOfPages = pages.count
scrollView.delegate = self
pageControl.addTarget(self, action: #selector(self.pgControlChange(_:)), for: .valueChanged)
}
var pgControlScroll: Bool = false
@objc func pgControlChange(_ sender: UIPageControl) {
pgControlScroll = true
let w = scrollView.frame.size.width
guard w != 0 else { return }
let x = scrollView.contentOffset.x
let cp = min(Int(round(x / w)), pages.count - 1)
let np = sender.currentPage
var r = CGRect.zero
if np > cp {
r = CGRect(x: w * CGFloat(np + 1) - 1.0, y: 0, width: 1, height: 1)
} else {
r = CGRect(x: w * CGFloat(np), y: 0, width: 1, height: 1)
}
scrollView.scrollRectToVisible(r, animated: true)
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
pgControlScroll = false
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let w = scrollView.frame.size.width
guard w != 0 else { return }
let x = scrollView.contentOffset.x
let pg = min(Int(round(x / w)), pages.count - 1)
let v = stack.arrangedSubviews[pg]
pageControl.backgroundColor = v.backgroundColor
if pgControlScroll { return }
pageControl.currentPage = pg
}
}
class BasePageController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// add a label at each corner
for (i, s) in ["top-left", "top-right", "bot-left", "bot-right"].enumerated() {
let v = UILabel()
v.backgroundColor = UIColor(white: 0.8, alpha: 1.0)
v.translatesAutoresizingMaskIntoConstraints = false
v.text = s
view.addSubview(v)
let g = view.safeAreaLayoutGuide
switch i {
case 1:
v.topAnchor.constraint(equalTo: g.topAnchor, constant: 4.0).isActive = true
v.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -4.0).isActive = true
case 2:
v.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -4.0).isActive = true
v.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 4.0).isActive = true
case 3:
v.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -4.0).isActive = true
v.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -4.0).isActive = true
default:
v.topAnchor.constraint(equalTo: g.topAnchor, constant: 4.0).isActive = true
v.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 4.0).isActive = true
}
}
}
}
Here is one way to do this...
Since the scroll view has paging enabled, we are able to have only 3 views "pages" in the scroll view at a time.
Assuming 4 total "pages"...
This approach loads all "page" view controllers as child view controllers. If there we end up with, say, 20 "pages" - particularly if they are "heavy" (lots of subviews, code, etc) - we would want to load controllers only when we need to show them, and unload them when they're removed from the 3 "scroll slots."
struct MyPage
Defines a page as a view controller and an index page number:
struct MyPage {
var vc: UIViewController!
var pageNumber: Int!
}
PagedScrollViewController class:
class PagedScrollViewController: UIViewController, UIScrollViewDelegate {
let scrollView: UIScrollView = {
let v = UIScrollView()
v.isPagingEnabled = true
v.showsHorizontalScrollIndicator = false
// set clipsToBounds to false
// if we want to see the way the views are being cycled
v.clipsToBounds = true
return v
}()
let pageControl: UIPageControl = {
let v = UIPageControl()
return v
}()
let stack: UIStackView = {
let v = UIStackView()
v.axis = .horizontal
v.distribution = .fillEqually
return v
}()
var pages: [MyPage] = []
override func viewDidLoad() {
super.viewDidLoad()
scrollView.translatesAutoresizingMaskIntoConstraints = false
pageControl.translatesAutoresizingMaskIntoConstraints = false
stack.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(stack)
view.addSubview(scrollView)
view.addSubview(pageControl)
let g = view.safeAreaLayoutGuide
let svCLG = scrollView.contentLayoutGuide
let svFLG = scrollView.frameLayoutGuide
NSLayoutConstraint.activate([
// cover most of screen (with a little padding on each side)
//scrollView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
//scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
//scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
//scrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -80.0),
// small scroll view at top of screen
scrollView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 60.0),
scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -60.0),
scrollView.heightAnchor.constraint(equalToConstant: 200.0),
stack.topAnchor.constraint(equalTo: svCLG.topAnchor, constant: 0.0),
stack.leadingAnchor.constraint(equalTo: svCLG.leadingAnchor, constant: 0.0),
stack.trailingAnchor.constraint(equalTo: svCLG.trailingAnchor, constant: 0.0),
stack.bottomAnchor.constraint(equalTo: svCLG.bottomAnchor, constant: 0.0),
stack.heightAnchor.constraint(equalTo: svFLG.heightAnchor, constant: 0.0),
pageControl.topAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: 8.0),
pageControl.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
pageControl.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
])
/*
// if we're loading "page" view controllers from Storyboard
var i = 0
if let vc = storyboard?.instantiateViewController(withIdentifier: "psFirst") as? PSFirstViewController {
pages.append(MyPage(vc: vc, pageNumber: i))
i += 1
}
if let vc = storyboard?.instantiateViewController(withIdentifier: "psSecond") as? PSSecondViewController {
pages.append(MyPage(vc: vc, pageNumber: i))
i += 1
}
if let vc = storyboard?.instantiateViewController(withIdentifier: "psThird") as? PSThirdViewController {
pages.append(MyPage(vc: vc, pageNumber: i))
i += 1
}
if let vc = storyboard?.instantiateViewController(withIdentifier: "psFourth") as? PSFourthViewController {
pages.append(MyPage(vc: vc, pageNumber: i))
i += 1
}
pages.forEach { pg in
self.addChild(pg.vc)
pg.vc.didMove(toParent: self)
}
*/
// for this example, we will
// create 4 "page" view controllers, with background colors
// (dark red, dark green, dark blue, brown)
let colors: [UIColor] = [
UIColor(red: 0.75, green: 0.00, blue: 0.00, alpha: 1.0),
UIColor(red: 0.00, green: 0.75, blue: 0.00, alpha: 1.0),
UIColor(red: 0.00, green: 0.00, blue: 0.75, alpha: 1.0),
UIColor(red: 0.75, green: 0.50, blue: 0.00, alpha: 1.0),
]
for (c, i) in zip(colors, Array(0..<colors.count)) {
let vc = BasePageController()
vc.view.backgroundColor = c
vc.centerLabel.text = "\(i + 1)"
self.addChild(vc)
vc.didMove(toParent: self)
pages.append(MyPage(vc: vc, pageNumber: i))
}
// move last page to position Zero
pages.insert(pages.removeLast(), at: 0)
// add 3 pages to stack view in scroll view
pages[0...2].forEach { pg in
stack.addArrangedSubview(pg.vc.view)
pg.vc.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: 0.0).isActive = true
}
scrollView.delegate = self
pageControl.numberOfPages = pages.count
pageControl.addTarget(self, action: #selector(self.pgControlChange(_:)), for: .valueChanged)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
scrollView.contentOffset.x = scrollView.frame.size.width
}
// flag so we don't infinite loop on scrolling and setting page control current page
var pgControlScroll: Bool = false
@objc func pgControlChange(_ sender: UIPageControl) {
let w = scrollView.frame.size.width
guard w != 0 else { return }
// get the middle page
let pg = pages[1]
// unwrap current page number in scroll view
guard let cp = pg.pageNumber else { return }
// set the flag
pgControlScroll = true
// next page based on page control page number
let np = sender.currentPage
var r = CGRect.zero
if np > cp {
r = CGRect(x: w * 3.0 - 1.0, y: 0.0, width: 1.0, height: 1.0)
// next page is to the right
} else {
// next page is to the left
r = CGRect(x: 0.0, y: 0, width: 1, height: 1)
}
// need to manually animate the scroll, so we can update our page order when scroll finishes
UIView.animate(withDuration: 0.3, animations: {
self.scrollView.scrollRectToVisible(r, animated: false)
}, completion: { _ in
self.updatePages(self.scrollView)
})
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
// turn off the flag
pgControlScroll = false
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let w = scrollView.frame.size.width
guard w != 0 else { return }
let x = scrollView.contentOffset.x
// get the "page" based on scroll offset x
let pgID = min(Int(round(x / w)), pages.count - 1)
let pg = pages[pgID]
guard let v = pg.vc.view else { return }
pageControl.backgroundColor = v.backgroundColor
// don't set the pageControl's pageNumber if we scrolled as a result of tapping the page control
if pgControlScroll { return }
pageControl.currentPage = pg.pageNumber
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
updatePages(scrollView)
}
func updatePages(_ scrollView: UIScrollView) -> Void {
let w = scrollView.frame.size.width
guard w != 0 else { return }
let x = scrollView.contentOffset.x
if x == 0 {
// we've scrolled to the left
// move last page to position Zero
guard let pg = pages.last,
let v = pg.vc.view else { return }
// remove the last arranged subview
stack.arrangedSubviews.last?.removeFromSuperview()
// insert last "page" view as first arranged subview
stack.insertArrangedSubview(v, at: 0)
// set its width anchor
v.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: 0.0).isActive = true
// move last page to first position in array
pages.insert(pages.removeLast(), at: 0)
} else if x == scrollView.frame.size.width * 2 {
// we've scrolled right
// move first page to last position in array
pages.append(pages.removeFirst())
// get the next page
let pg = pages[2]
guard let v = pg.vc.view else { return }
// remove first arranged subview
stack.arrangedSubviews.first?.removeFromSuperview()
// add next page view to stack
stack.addArrangedSubview(v)
// set its width anchor
v.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: 0.0).isActive = true
}
scrollView.contentOffset.x = scrollView.frame.size.width
}
}
BasePageController example "page" class:
class BasePageController: UIViewController {
let centerLabel: UILabel = {
let v = UILabel()
v.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
v.font = UIFont.systemFont(ofSize: 48.0, weight: .bold)
v.textAlignment = .center
return v
}()
override func viewDidLoad() {
super.viewDidLoad()
// add a label at each corner
for (i, s) in ["top-left", "top-right", "bot-left", "bot-right"].enumerated() {
let v = UILabel()
v.backgroundColor = UIColor(white: 0.8, alpha: 1.0)
v.translatesAutoresizingMaskIntoConstraints = false
v.text = s
view.addSubview(v)
let g = view.safeAreaLayoutGuide
switch i {
case 1:
v.topAnchor.constraint(equalTo: g.topAnchor, constant: 4.0).isActive = true
v.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -4.0).isActive = true
case 2:
v.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -4.0).isActive = true
v.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 4.0).isActive = true
case 3:
v.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -4.0).isActive = true
v.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -4.0).isActive = true
default:
v.topAnchor.constraint(equalTo: g.topAnchor, constant: 4.0).isActive = true
v.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 4.0).isActive = true
}
}
centerLabel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(centerLabel)
NSLayoutConstraint.activate([
centerLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
centerLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
}
}
Notes:
This is example code only and should not be considered "production ready."
If you have only 1 or 2 "pages" this will crash.
If you try this with a couple dozen "pages" you'll likely hit memory problems.
Edit based on comments...
Took a look at your project, and I see you're using a UICollectionView
instead.
I think the issue is that you're mixing / matching your viewModel.pages
- which has 4
elements, and your itemsWithBoundries
- which has 6
elements. Trying to reconcile that is pretty messy.
So... I'm going to suggest a different, older approach.
For the collection view's numberOfItemsInSection
, I'm going to return 10,000.
In cellForItemAt
, I'll use indexPath.item % viewModel.pages.count
(the remainder / modulo operator) to return a cell in the viewModel.pages[0...3]
range.
Same idea in scrollViewDidScroll
... get the actual cell item index %
number of pages to get Zero thru 3.
To achieve "infinite scrolling" in both directions, I'll start with scrolling the collection view to item 5,000 (code includes adjusting that if the number of pages is not equally divisible into 5,000). It's pretty unlikely a user would scroll 5,000 pages in either direction to reach the "end."
I edited your Test App with that approach and posted it to GitHub: https://github.com/DonMag/Test-App so you can see the changes I made.