Search code examples
iosswiftuiscrollviewuistackview

Swift - Infinite Scrolling for UIScrollView with an embedded UIStackView


Someone helped with the below code to achieve what I needed to do here:-

UIPageViewController - Detect scrolling halfway into the next view controller (almost working) to change button color?

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

Solution

  • 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"...

    • Start with pages 4, 1, 2
    • set scroll content offset x so the center page is visible
    • if we scroll to the next page, shift the views to 1, 2, 3 and again set scroll content offset x so the center page is visible
    • if we scroll to the previous page, shift the views to 3, 4, 1 and again set scroll content offset x so the center page is visible

    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.