Infinite scroll with banners, or How to do with three views


Each developer for mobile platforms is constantly faced with a task that cannot be solved in one single way. There are always several ways, some quick, some complicated, and each has its own advantages and disadvantages.

The endless / cyclic scroll in iOS cannot be implemented using standard tools, you need to go to different tricks. In this article I will tell you which options for solving the problem lie on the surface and which option we eventually implemented.

Task


It was necessary to make an endless cyclic scrolling with elements in the form of a prepared picture, title and subtitle. Input: the central image is indented from the screen by 16.0 points. On the sides of the central image, “ears” stick out. And the distance between the banners is 8.0 points.


We study what has been done by colleagues



Dodo - banners are not cyclical, the central banner is always adjacent to the left edge at a certain distance.

Auto.ru - banners are not cyclical, if you swipe, then the banners are flipped through for a very long time.

Ozon - banners are cyclical, but they cannot be controlled by touch: once the direction of the swipe is determined, the picture can no longer be stopped.

Wildberries - banners are not cyclical, centering occurs, long completion of the scroll animation.

Final wishes:

  • Banners should be centered.
  • Scrolling should end without a long wait for the animation.
  • Manageability of banners: there should be the ability to control the scroll animation with your finger.

Implementation options


When a new task arises that has not yet been solved, it is worth considering existing solutions and approaches.

UICollectionView


Acting in the forehead, you can come to the option of creating UICollectionView. We make the number of elements Int.maxand during initialization we show the middle, and when calling the method in dataSource- func collectionView(UICollectionView, cellForItemAt: IndexPath) -> UICollectionViewCell. We will return the corresponding element, assuming that the zero element is this Int.max / 2. Such a monster with a bunch of features, like UICollectionView, is inappropriate to use for our simple task.

UIScrollView and (n + 2) UIView


There is still an option in which UIScrollView is created, absolutely all banners are placed on it, and it is added to the beginning and end by the banner. When we finish it to the end, we change the offset imperceptibly for the user and return to the first element. And when scrolling back, we do the opposite. As a result, with a large number of elements, a bunch of views will be created without reusing them.


Source

Own way


We decided to make UIScrollView + three UIView. These UIView will be reused. At the time of scrolling, we will return contentOffsetto the central banner and replace the content of all three UIView. And then you should get a light component that closes this task.

However, there is a concern that content spoofing during scrolling will be noticeable to the user. We learn about this during implementation.

Implementation


Preparing UIScrollView and Three UIImageView


Create an heir UIView, place UIScrollViewthree on it 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)
    }
}

Add the implementation of the method with the setting scrollView:

  • decelerationRate- This parameter indicates how fast the scroll animation will slow down. In our case, it’s best .fast.
  • showsHorizontalScrollIndicator - this parameter is responsible for displaying the horizontal scrollbar:

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

After the basic setup, we can do the layout and placement 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)
}

Add to the UIImageViewimages that we’ll pull up from the image generator site https://placeholder.com :

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

The result of the first preparatory steps:


Center images when scrolling


To control the scroll we will use UIScrollViewDelegate. We setupset the delegate for to the method UIScrollView, and also set contentInsetthe first and last images to have indents on the sides.

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

We create extensionfor our BannersViewone of the methods. The delegate method func scrollViewWillEndDraggingis called when the user stops scrolling. In this method we are interested in targetContentOffset- this is the variable that is responsible for the final scroll offset (the point at which the scroll stops).


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- this is the distance at which we will assume that view is central. If a third of the width of the orange image is displayed on the screen, then we will set the final offset so that the orange image is in the center.


targetRightOffsetX - This point will help determine whether the right view is central.


The result of the implementation of this method:


Manage offset while scrolling


Now, right during scrolling, we will change contentOffset, returning to the center of the screen the central view. This will allow the user to create an illusion of infinite scrolling unnoticed.

Add a delegate method func scrollViewDidScroll(_ scrollView: UIScrollView), it is called when contentOffsety changes 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- this is the distance from which we will determine the need for displacement contentOffset. We calculate the point for rightItemView:, self.rightItemView.frame.maxX — gapafter the intersection of which we will shift contentOffset. For example, if rightItemView100.0 points remain to be scrolled to full display , then we shift contentOffsetbackward, by the width of one banner, taking into account the distance between the banners (spacing), so that it is centerItemViewin place rightItemView. Similarly, we do for leftItemView: we calculate the point, after the intersection of which we will change contentOffset.


Add a method func set(imageURLs: [URL])to expose data for display outside. There we transfer part of the code from setup.

And we’ll also add a line so that when you set the content you’ll centerItemViewimmediately be centered. horizontalItemOffsetFromSuperViewwe already used in layoutSubviews, so we put it into constants and use it again.

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
}

We will call this method outside in UIViewController.viewDidAppear. Or you can move the first alignment to layoutSubviews, but verify that this will only be done by changing the frame of the entire view. To demonstrate the work, we will use the first method:


So ... With a sharp scroll, centering broke.


The fact is that with strong scrolling it is ignored targetContentOffset. Let's increase contentInset, after that everything works correctly. The central view will always be centered.

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


We replace content


The task is to contentOffsetreplace the content of the view at offset at the same time. When scrolling to the right, the right image will become central, the center will become left, and the left will be right. 1 - 2 - 3 | 2 - 3 - 1.

For convenience, create a ViewModel:

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

To check which element is now in the center, add a variable to BannersViewand variables with content for each of the view:

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

leftItemViewModel, centerItemViewModel, rightItemViewModel- on the basis of currentCenterItemIndexreturn relevant content for each view. force unwrapand fatalhere we use it because the number of elements is ≥ 3 (if desired, you can add a check to the method set).

Add methods that will be called if necessary to change the content of views:

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

Change the method used externally to expose content:

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

And we will call nextItem, and prevItemin the method the delegate when changing 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()
        }
    }

Let's increase the number of input links to images to 5 (for convenience there were three):


Final steps


It remains to make custom UIViewinstead of a simple picture. This will be the title, subtitle and image.

Extend ViewModel:

struct BannersViewModel {
    let items: [Item]

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

And write a banner implementation:

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

Replace UIImageViewand ViewModelin BannersView::


    .......

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

    .......

Result:


findings


Making an endless looping scroll with banners turned out to be an interesting task. I am sure that everyone will be able to draw their own conclusions or draw any ideas from our decision to manage with only three reusable ones UIView.

Once again, we were convinced that the solutions that come to mind first and the solutions that you can find on the Internet are not always optimal. At first, we were afraid that replacing content during scrolling would lead to a problem, but everything worked smoothly. Do not be afraid to try your approaches if you think this is right. Think with your own head :).

All Articles