带有横幅的无限滚动,或者如何处理三个视图


每个移动平台开发人员始终面临着无法以一种单一方式解决的任务。总有几种方法,有些是快速的,有些是复杂的,每种都有其优点和缺点。

iOS中的无穷/循环滚动无法使用标准工具实现,您需要使用不同的技巧。在本文中,我将告诉您解决问题的方法是表面的,以及最终实现的方法。

任务


必须使用准备好的图片,标题和副标题的形式对元素进行无休止的循环滚动。输入:中央图像从屏幕缩进16.0点。在中央图像的侧面,“耳朵”突出。横幅之间的距离为8.0点。


我们研究同事所做的事情



渡渡鸟(Dodo)-标语不是周期性的,中央标语始终与左边缘相邻一定距离。

Auto.ru-条幅不是周期性的,如果您滑动,条幅会翻转很长时间。

臭氧-横幅是周期性的,但不能通过触摸来控制:确定了滑动方向后,就无法再停止图片了。

野莓-横幅不是周期性的,会出现居中,滚动动画会长时间完成。

最后的祝愿:

  • 标语应居中。
  • 滚动应该结束,而无需长时间等待动画。
  • 标语的可管理性:应该可以用手指控制滚动动画。

实施方案


当出现尚未解决的新任务时,值得考虑现有的解决方案和方法。

UICollectionView


通过额头行动,您可以选择创建UICollectionView我们计算元素的数量,Int.max并在初始化期间显示中间的元素,并在dataSource-中调用方法func collectionView(UICollectionView, cellForItemAt: IndexPath) -> UICollectionViewCell假设零元素是this,我们将返回相应的元素Int.max / 2这样的具有很多特征的怪物(例如UICollectionView)不适合用于我们的简单任务。

UIScrollView和(n + 2)UIView


还有一个创建UIScrollView的选项,绝对所有横幅都放置在上面,并由横幅添加到开头和结尾。当我们将其完成到最后时,我们会无意识地为用户更改偏移量,然后返回第一个元素。当回滚时,我们做相反的事情。结果,在具有大量元素的情况下,将创建一堆视图而不重用它们。


资源

自己的方式


我们决定制作UIScrollView +三个UIView。这些UIView将被重用。滚动时,我们将返回contentOffset中心横幅并替换所有三个UIView的内容。然后,您应该获得一个轻型组件来完成此任务。

然而,存在这样的担忧,即在滚动期间的内容欺骗对于用户而言将是明显的。我们在实施过程中了解到这一点。

实作


准备UIScrollView和三个UIImageView


创建一个继承人UIView,在其上放置UIScrollView三个UIImageView

final class BannersView: UIView {
    private let scrollView = UIScrollView()

    private let leftItemView = UIImageView()
    private let centerItemView = UIImageView()
    private let rightItemView = UIImageView()

    init() {
        super.init(frame: .zero)
        self.setup()
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func setup() {
        self.addSubview(self.scrollView)
        self.setupScrollView()

        let imageViews = [self.leftItemView, self.centerItemView, self.rightItemView]
        imageViews.forEach(self.scrollView.addSubview)
    }
}

通过设置添加方法的实现scrollView

  • decelerationRate-此参数指示滚动动画的放慢速度。就我们而言,这是最好的.fast
  • showsHorizontalScrollIndicator -此参数负责显示水平滚动条:

    private func setupScrollView() {
        self.scrollView.decelerationRate = .fast
        self.scrollView.showsHorizontalScrollIndicator = false
    }
    

完成基本设置后,我们可以进行布局和放置ImageView

override func layoutSubviews() {
    super.layoutSubviews()
    self.scrollView.frame = self.bounds

    let horizontalItemOffsetFromSuperView: CGFloat = 16.0
    let spaceBetweenItems: CGFloat = 8.0
    let itemWidth = self.frame.width - horizontalItemOffsetFromSuperView * 2
    let itemHeight: CGFloat = self.scrollView.frame.height

    var startX: CGFloat = 0.0

    let imageViews = [self.leftItemView, self.centerItemView, self.rightItemView]
    imageViews.forEach { view in
        view.frame.origin = CGPoint(x: startX, y: 0.0)
        view.frame.size = CGSize(width: itemWidth, height: itemHeight)
        startX += itemWidth + spaceBetweenItems
    }

    let viewsCount: CGFloat = 3.0
    let contentWidth: CGFloat = itemWidth * viewsCount + spaceBetweenItems * (viewsCount - 1.0)
    self.scrollView.contentSize = CGSize(width: contentWidth, height: self.frame.height)
}

添加到UIImageView我们将从图像生成器网站https://placeholder.com提取的图像

    let imageURLs = ImageURLFactory.makeImageURLS()
    imageViews.enumerated().forEach { key, view in
        view.setImage(with: imageURLs[key])
    }

第一步准备的结果:


滚动时使图像居中


为了控制滚动,我们将使用UIScrollViewDelegate我们setup方法设置了委托UIScrollView,还设置contentInset了第一张和最后一张图像在侧面有凹痕。

self.scrollView.contentInset = UIEdgeInsets(top: 0.0, left: 16.0, bottom: 0.0, right: 16.0)
self.scrollView.delegate = self

我们extension为我们BannersView的方法之一创建func scrollViewWillEndDragging当用户停止滚动时,将调用委托方法在此方法中,我们感兴趣targetContentOffset-这是负责最终滚动偏移量(滚动停止的点)的变量。


extension BannersView: UIScrollViewDelegate {

  func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
    let gap: CGFloat = self.centerItemView.frame.width / 3

    let targetRightOffsetX = targetContentOffset.pointee.x + self.frame.width
    if (self.rightItemView.frame.minX + gap) < targetRightOffsetX {
      targetContentOffset.pointee.x = self.rightItemView.frame.midX - self.frame.midX
    }
    else if (self.leftItemView.frame.maxX - gap) > targetContentOffset.pointee.x {
      targetContentOffset.pointee.x = self.leftItemView.frame.midX - self.frame.midX
    }
    else {
      targetContentOffset.pointee.x = self.centerItemView.frame.midX - self.frame.midX
    }
  }

}

gap-这是我们假定视图处于中心位置的距离。如果在屏幕上显示橙色图像宽度的三分之一,则我们将设置最终偏移量,以使橙色图像位于中间。


targetRightOffsetX -这一点将有助于确定正确的视图是否居中。


执行此方法的结果:


滚动时管理偏移


现在,在滚动过程中,我们将更改contentOffset,将中心视图返回到屏幕中心。这将使用户产生一种无人注意的无限滚动错觉。

添加一个委托方法func scrollViewDidScroll(_ scrollView: UIScrollView),当contentOffsety 更改时调用该方法UIScrollView


func scrollViewDidScroll(_ scrollView: UIScrollView) {
    guard
            self.leftItemView.frame.width > 0,
            self.centerItemView.frame.width > 0,
            self.rightItemView.frame.width > 0
            else {
        return
    }

    let gap: CGFloat = self.centerItemView.frame.width / 3
    let spacing: CGFloat = 8.0

    let currentRightOffset: CGFloat = scrollView.contentOffset.x + self.frame.width + scrollView.contentInset.left

    if (self.rightItemView.frame.maxX - gap) < currentRightOffset {
        scrollView.contentOffset.x -= self.centerItemView.frame.width + spacing
    } else if (self.leftItemView.frame.minX + gap) > scrollView.contentOffset.x {
        scrollView.contentOffset.x += self.centerItemView.frame.width + spacing
    }
}

gap-这是我们确定位移需求的距离contentOffset我们计算出:的点rightItemViewself.rightItemView.frame.maxX — gap在该点的交点之后我们将进行平移contentOffset例如,如果rightItemView仍有100.0点要滚动到完整显示,则contentOffset考虑到横幅之间的距离(间距),我们将向后移动一个横幅的宽度,以使其centerItemView就位rightItemView类似地,我们这样做leftItemView:计算点,在该点的交点之后我们将改变contentOffset


添加一种方法func set(imageURLs: [URL])以公开数据以在外部显示。在那里,我们从中转移了部分代码setup

我们还将添加一行,以便在您设置内容时将其centerItemView立即居中。horizontalItemOffsetFromSuperView我们已经在中使用过layoutSubviews,因此我们将其放入常量中并再次使用。

func set(imageURLs: [URL]) {
    //    ImageView
    let imageViews = [self.leftItemView, self.centerItemView, self.rightItemView]
    imageViews.enumerated().forEach { key, view in
        view.setImage(with: imageURLs[key])
    }
    //    ,  centerItemView   
    self.scrollView.contentOffset.x = self.centerItemView.frame.minX - Constants.horizontalItemOffsetFromSuperView
}

我们将在中调用此方法UIViewController.viewDidAppear或者,您可以将第一个路线移至layoutSubviews,但请确保仅在更改整个视图的框架时才能执行此操作。为了演示这项工作,我们将使用第一种方法:


如此……随着剧烈的滚动,居中破裂了。


事实是,在强力滚动时会忽略它targetContentOffset让我们增加contentInset,之后一切正常。中心视图将始终居中。

self.scrollView.contentInset = UIEdgeInsets(top: 0.0, left: 300.0, bottom: 0.0, right: 300.0)


我们替换内容


任务是同时contentOffset替换偏移量的视图内容。向右滚动时,右边的图像将成为中心,中心将变为左边,而左边将为右边。1-2-3 |

2-3-1 . 为方便起见,创建一个ViewModel:

struct BannersViewModel {
    //     3     
    let items: [URL] = ImageURLFactory.makeImageURLS()
}

要检查当前位于中心的元素,请向BannersView每个视图中添加一个变量和带有内容的变量:

    private var currentCenterItemIndex: Int = 0

    private var viewModel: BannersViewModel?

    private var leftItemViewModel: URL {
        guard let items = self.viewModel?.items else { fatalError("not ready") }
        let leftIndex = items.index(before: self.currentCenterItemIndex)
        return leftIndex < 0 ? items.last! : items[leftIndex]
    }
    private var centerItemViewModel: URL {
        guard let items = self.viewModel?.items else { fatalError("not ready") }
        return items[self.currentCenterItemIndex]
    }
    private var rightItemViewModel: URL {
        guard let items = self.viewModel?.items else { fatalError("not ready") }
        let rightIndex = items.index(after: self.currentCenterItemIndex)
        return rightIndex >= items.count ? items.first! : items[rightIndex]
    }

leftItemViewModelcenterItemViewModelrightItemViewModel-的基础上,currentCenterItemIndex为每个视图返回相关内容。force unwrapfatal在这里,我们使用它,因为元素的数量≥3(如果需要的话,你可以添加一个检查的方法set)。

添加将在必要时更改视图内容的方法:

    func nextItem() {
        self.currentCenterItemIndex += 1
        if self.viewModel?.items.count == self.currentCenterItemIndex {
            self.currentCenterItemIndex = 0
        }
        self.updateViews()
    }

    func prevItem() {
        self.currentCenterItemIndex -= 1
        if self.currentCenterItemIndex == -1 {
            self.currentCenterItemIndex = self.viewModel?.items.indices.last ?? 0
        }
        self.updateViews()
    }

    private func updateViews() {
        self.leftItemView.setImage(with: self.leftItemViewModel)
        self.centerItemView.setImage(with: self.centerItemViewModel)
        self.rightItemView.setImage(with: self.rightItemViewModel)
    }

更改外部用于公开内容的方法:

    func set(viewModel: BannersViewModel) {
        self.viewModel = viewModel
        self.updateViews()
        self.scrollView.contentOffset.x = self.centerItemView.frame.minX - Constants.horizontalItemOffsetFromSuperView
    }

然后我们将调用nextItem,并prevItem在方法中更改时委托contentOffset

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        .......

        if (self.rightItemView.frame.maxX - gap) < currentRightOffset {
            scrollView.contentOffset.x -= self.centerItemView.frame.width + spacing
            self.nextItem()
        } else if (self.leftItemView.frame.minX + gap) > scrollView.contentOffset.x {
            scrollView.contentOffset.x += self.centerItemView.frame.width + spacing
            self.prevItem()
        }
    }

让我们将图像的输入链接数增加到5(为方便起见,有3个):


最后步骤


仍然需要定制UIView而不是简单的图片。这将是标题,副标题和图像。

扩展ViewModel

struct BannersViewModel {
    let items: [Item]

    struct Item {
        let title: String
        let subtitle: String
        let imageUrl: URL
    }
}

并编写一个横幅实现:

extension BannersView {
    final class ItemView: UIView {
        private let titleLabel = UILabel()
        private let subtitleLabel = UILabel()
        private let imageView = UIImageView()

        init() {
            super.init(frame: .zero)
            self.setup()
        }

        required init?(coder aDecoder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }

        private func setup() {
            self.addSubview(self.imageView)
            self.addSubview(self.titleLabel)
            self.addSubview(self.subtitleLabel)

            self.imageView.contentMode = .scaleAspectFill

            self.layer.masksToBounds = true
            self.layer.cornerRadius = 8.0
        }

        func set(viewModel: BannersViewModel.Item) {
            self.titleLabel.text = viewModel.title
            self.subtitleLabel.text = viewModel.subtitle
            self.imageView.setImage(with: viewModel.imageUrl)
        }

        override func layoutSubviews() {
            super.layoutSubviews()
            self.imageView.frame = self.bounds

            self.titleLabel.frame.origin = CGPoint(x: 16.0, y: 16.0)
            self.titleLabel.frame.size = CGSize(width: self.bounds.width - 32.0, height: 20.0)

            self.subtitleLabel.frame.origin = CGPoint(x: 16.0, y: self.titleLabel.frame.maxY + 4.0)
            self.subtitleLabel.frame.size = self.titleLabel.frame.size
        }
    }
}

替换UIImageViewViewModelBannersView::


    .......

    private let leftItemView = ItemView()
    private let centerItemView = ItemView()
    private let rightItemView = ItemView()
    
    private var leftItemViewModel: BannersViewModel.Item { ... }
    private var centerItemViewModel: BannersViewModel.Item { ... }
    private var rightItemViewModel: BannersViewModel.Item { ... }

    .......

    private func updateViews() {
        self.leftItemView.set(viewModel: self.leftItemViewModel)
        self.centerItemView.set(viewModel: self.centerItemViewModel)
        self.rightItemView.set(viewModel: self.rightItemViewModel)
    }

    .......

结果:


发现


制作带有横幅的无尽循环滚动是一项有趣的任务。我相信每个人都可以得出自己的结论或从我们的决策中得出任何想法,只有三个可重复使用的想法UIView

我们再次确信,首先想到的解决方案和您在Internet上可以找到的解决方案并非总是最佳的。起初,我们担心在滚动过程中替换内容会导致问题,但是一切都进行得很顺利。如果您认为这是对的,请不要害怕尝试您的方法。想一想:)。

All Articles