简体   繁体   中英

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

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.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM