Swift - 带有嵌入式 UIStackView 的 UIScrollView 的无限滚动

Swift - Infinite Scrolling for UIScrollView with an embedded UIStackView

有人用下面的代码帮助实现了我在这里需要做的事情:-

在保持现有行为不变的同时,我需要一些帮助,请修改 scrollViewDidScroll 方法以允许无限平滑滚动,因此当您向右滑动到达第四个/最后一个项目时,它将平滑过渡到第一页,同样,如果您在到达第一页时继续向左滑动,然后再次向左滑动,将显示最后一个项目,如果您明白我的意思,我尝试过这样做,但有点过头了。谢谢

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

这是一种方法...

由于滚动视图启用了分页,我们一次只能在滚动视图中显示 3 个视图“页面”。

假设总共有 4 个“页面”...

  • 从第 4、1、2 页开始
  • 设置滚动内容偏移 x 以便中心页面可见
  • 如果我们滚动到下一页,将视图移动到 1、2、3 并再次设置滚动内容偏移 x 以便中心页面可见
  • 如果我们滚动到上一页,将视图移动到 3、4、1 并再次设置滚动内容偏移量 x,以便中心页面可见

此方法将所有“页面”视图控制器加载为子视图控制器。如果我们最终得到 20 个“页面”——特别是如果它们“很重”(很多子视图、代码等)——我们希望仅在需要显示它们时加载控制器,并在需要时卸载它们'从 3 个“滚动槽”中移除。


struct MyPage 定义一个页面作为视图控制器和索引页码:

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 示例“页面”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),
        ])
    }
    
}

备注:

这只是 示例代码,不应被视为“生产就绪”。

如果您只有 1 或 2 个“页面”,这将崩溃。

如果你用几十个“页面”尝试这个,你可能会遇到内存问题。


编辑 根据评论...

查看了您的项目,我发现您使用的是 UICollectionView

我认为问题在于您正在混合/匹配 viewModel.pages - 具有 4 元素,而 itemsWithBoundries - 具有 6 元素。试图调和那是相当混乱的。

所以...我将建议一种不同的、较旧的方法。

对于集合视图的 numberOfItemsInSection,我要 return 10,000。

cellForItemAt 中,我将使用 indexPath.item % viewModel.pages.count(余数/取模运算符)来 return viewModel.pages[0...3] 范围内的单元格。

scrollViewDidScroll 中有相同的想法 ... 获取实际的单元格项目索引 % 页数,从零到 3。

为了在两个方向上实现“无限滚动”,我将从将集合视图滚动到第 5,000 项开始(代码包括在页数不能等分为 5,000 时进行调整)。用户不太可能在任一方向滚动 5,000 页以到达“结尾”。

我用这种方法编辑了您的测试应用程序并将其发布到 GitHub:https://github.com/DonMag/Test-App 这样您就可以看到我所做的更改。