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.max
and 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.SourceOwn way
We decided to make UIScrollView + three UIView. These UIView will be reused. At the time of scrolling, we will return contentOffset
to 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 UIScrollView
three 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
: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 UIImageView
images 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 setup
set the delegate for to the method UIScrollView
, and also set contentInset
the 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 extension
for our BannersView
one of the methods. The delegate method func scrollViewWillEndDragging
is 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 contentOffset
y 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 — gap
after the intersection of which we will shift contentOffset
. For example, if rightItemView
100.0 points remain to be scrolled to full display , then we shift contentOffset
backward, by the width of one banner, taking into account the distance between the banners (spacing), so that it is centerItemView
in 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 centerItemView
immediately be centered. horizontalItemOffsetFromSuperView
we already used in layoutSubviews
, so we put it into constants and use it again.func set(imageURLs: [URL]) {
let imageViews = [self.leftItemView, self.centerItemView, self.rightItemView]
imageViews.enumerated().forEach { key, view in
view.setImage(with: imageURLs[key])
}
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 contentOffset
replace 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 {
let items: [URL] = ImageURLFactory.makeImageURLS()
}
To check which element is now in the center, add a variable to BannersView
and 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 currentCenterItemIndex
return relevant content for each view. force unwrap
and fatal
here 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 prevItem
in 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 UIView
instead 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 UIImageView
and ViewModel
in 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 :).