Каждый разработчик под мобильные платформы постоянно сталкивается с задачей, которая не решается одним единственным способом. Всегда есть несколько путей, — какие-то быстрые, какие-то сложные, — и у каждого свои достоинства и недостатки.
Бесконечный/цикличный скролл в iOS стандартными средствами не реализовать, нужно идти на разные ухищрения. В этой статье я расскажу, какие варианты решения задачи лежат на поверхности и какой вариант мы в итоге реализовали.
Задача
Нужно было сделать бесконечную цикличную прокрутку с элементами в виде подготовленной картинки, заголовка и подзаголовка. Вводные данные: центральное изображение имеет отступы от экрана по 16.0 поинтов. По бокам от центрального изображения торчат «ушки». А расстояние между баннерами 8.0 поинтов.
Изучаем, что сделано у коллег
Додо — баннеры не цикличные, центральный баннер всегда прилегает к левому краю на определенном расстоянии.
Auto.ru — баннеры не цикличные, если сделать свайп, то баннеры очень долго еще листаются.
Ozon — баннеры цикличные, но их нельзя контролировать касанием: как только определяется направление свайпа, картинку уже не остановить.
Wildberries — баннеры не цикличные, происходит центрирование, долгое завершение анимации прокрутки.
Итоговые пожелания:
- Баннеры должны центрироваться.
- Прокрутка должна завершаться без долгого ожидания анимации.
- Управляемость баннеров: должна быть возможность пальцем контролировать анимацию прокрутки.
Варианты реализации
Когда встает новая задача, которую еще не приходилось решать, стоит рассмотреть существующие решения и подходы.
UICollectionView
Действуя в лоб, можно прийти к варианту с созданием UICollectionView
. Делаем количество элементов Int.max
и при инициализации показываем середину, а при вызове метода в dataSource
— func collectionView(UICollectionView, cellForItemAt: IndexPath) -> UICollectionViewCell
. Будем возвращать соответствующий элемент, рассчитывая, что нулевой элемент — это Int.max / 2
. Такого монстра с кучей возможностей, как UICollectionView
, нецелесообразно использовать для нашей простой задачи.
UIScrollView и (n + 2) UIView
Еще есть вариант при котором создаётся UIScrollView, на нем размещаются абсолютно все баннеры, а в начало и в конец добавляется еще по баннеру. Когда докручиваем до конца, незаметно для пользователя меняем оффсет и возвращаемся к первому элементу. А при прокрутке назад всё делаем наоборот. В результате при большом количестве элементов будет создана куча view без их повторного использования.
Свой путь
Мы решили сделать 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
— это переменная, которая отвечает за конечный offset прокрутки (точка, в которой остановится прокрутка).
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
— это расстояние при котором мы будем считать, что view является центральным. Если на экране отображается треть ширины оранжевого изображения, то мы будем выставлять конечный offset таким образом, чтобы оранжевое изображение оказалось в центре.
targetRightOffsetX
— эта точка поможет определить, является ли правый view центральным.
Результат работы реализации данного метода:
Управляем оффсетом во время прокрутки
Теперь прямо во время прокрутки будем менять contentOffset
, возвращая в центр экрана центральную view. Это позволит незаметно для пользователя создать иллюзию бесконечной прокрутки.
Добавим метод делегата func scrollViewDidScroll(_ scrollView: UIScrollView)
, он вызывается при изменении contentOffset
у 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
. Рассчитаем точку для rightItemView
: self.rightItemView.frame.maxX — gap
, после пересечения которой будем смещать contentOffset
. Например, если до полного отображения rightItemView
останется прокрутить 100.0 поинтов, то мы смещаем contentOffset
назад, на ширину одного баннера с учетом расстояния между баннерами (spacing), чтобы 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
, но проверять, что это будет сделано только при изменение frame всей view. Для демонстрации работы воспользуемся первым способом:
Так… При резкой прокрутке сломалось центрирование.
Дело в том, что при сильной прокрутке игнорируется targetContentOffset
. Увеличим contentInset
, после этого всё работает корректно. Центральный view всегда будет по центру.
self.scrollView.contentInset = UIEdgeInsets(top: 0.0, left: 300.0, bottom: 0.0, right: 300.0)
Подменяем контент
Задача состоит в том, чтобы при смещении contentOffset
одновременно заменять контент у view. При прокрутке в правую сторону правое изображение станет центральным, центральное станет левым, а левое — правым. 1 — 2 — 3 | 2 — 3 — 1.
Для удобства создадим ViewModel:
struct BannersViewModel {
// здесь у нас гарантированно 3 ссылки или более на изображения
let items: [URL] = ImageURLFactory.makeImageURLS()
}
Чтобы проверить, какой элемент сейчас в центре, добавим переменную в BannersView
и переменные с контентом для каждой из 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
— на основе currentCenterItemIndex
возвращаем нужный контент для каждой view. force unwrap
и fatal
здесь используем потому, что количество элементов ≥ 3 (при желании, можно добавить проверку в метод set
).
Добавим методы, которые будут вызываться при необходимости изменить контент у 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)
}
Изменим метод, который используется снаружи для выставления контента:
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 (для удобства было три):
Финальные шаги
Осталось сделать кастомную 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
}
}
}
Заменим UIImageView
и ViewModel
в 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)
}
.......
Результат:
Выводы
Сделать бесконечный цикличный скролл с баннерами оказалось интересной задачей. Уверен, что каждый сможет сделать свои выводы или почерпнуть какие-либо идеи из нашего решения обойтись всего лишь тремя переиспользуемыми UIView
.
Еще раз мы убедились, что решения, которые приходят в голову первыми, и решения которые вы можете найти в интернете, не всегда являются оптимальными. Сначала мы опасались, что подменять контент во время прокрутки приведёт к проблеме, но всё работает гладко. Не бойтесь пробовать свои подходы, если считаете, что это правильно. Думайте своей головой :).
Автор: Dmitrii Chikovinskii