Бесконечный скролл с баннерами, или Как обойтись тремя вьюхами

в 12:24, , рубрики: swift, Блог компании Юла, разработка мобильных приложений, разработка под iOS
Бесконечный скролл с баннерами, или Как обойтись тремя вьюхами - 1

Каждый разработчик под мобильные платформы постоянно сталкивается с задачей, которая не решается одним единственным способом. Всегда есть несколько путей, — какие-то быстрые, какие-то сложные, — и у каждого свои достоинства и недостатки.

Бесконечный/цикличный скролл в iOS стандартными средствами не реализовать, нужно идти на разные ухищрения. В этой статье я расскажу, какие варианты решения задачи лежат на поверхности и какой вариант мы в итоге реализовали.

Задача

Нужно было сделать бесконечную цикличную прокрутку с элементами в виде подготовленной картинки, заголовка и подзаголовка. Вводные данные: центральное изображение имеет отступы от экрана по 16.0 поинтов. По бокам от центрального изображения торчат «ушки». А расстояние между баннерами 8.0 поинтов.

Бесконечный скролл с баннерами, или Как обойтись тремя вьюхами - 2

Изучаем, что сделано у коллег

Бесконечный скролл с баннерами, или Как обойтись тремя вьюхами - 3

Додо — баннеры не цикличные, центральный баннер всегда прилегает к левому краю на определенном расстоянии.

Auto.ru — баннеры не цикличные, если сделать свайп, то баннеры очень долго еще листаются.

Ozon — баннеры цикличные, но их нельзя контролировать касанием: как только определяется направление свайпа, картинку уже не остановить.

Wildberries — баннеры не цикличные, происходит центрирование, долгое завершение анимации прокрутки.

Итоговые пожелания:

  • Баннеры должны центрироваться.
  • Прокрутка должна завершаться без долгого ожидания анимации.
  • Управляемость баннеров: должна быть возможность пальцем контролировать анимацию прокрутки.

Варианты реализации

Когда встает новая задача, которую еще не приходилось решать, стоит рассмотреть существующие решения и подходы.

UICollectionView

Действуя в лоб, можно прийти к варианту с созданием UICollectionView. Делаем количество элементов Int.max и при инициализации показываем середину, а при вызове метода в dataSourcefunc collectionView(UICollectionView, cellForItemAt: IndexPath) -> UICollectionViewCell. Будем возвращать соответствующий элемент, рассчитывая, что нулевой элемент — это Int.max / 2. Такого монстра с кучей возможностей, как UICollectionView, нецелесообразно использовать для нашей простой задачи.

UIScrollView и (n + 2) UIView

Еще есть вариант при котором создаётся UIScrollView, на нем размещаются абсолютно все баннеры, а в начало и в конец добавляется еще по баннеру. Когда докручиваем до конца, незаметно для пользователя меняем оффсет и возвращаемся к первому элементу. А при прокрутке назад всё делаем наоборот. В результате при большом количестве элементов будет создана куча view без их повторного использования.

Бесконечный скролл с баннерами, или Как обойтись тремя вьюхами - 4

Источник

Свой путь

Мы решили сделать 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 таким образом, чтобы оранжевое изображение оказалось в центре.

Бесконечный скролл с баннерами, или Как обойтись тремя вьюхами - 5

targetRightOffsetX — эта точка поможет определить, является ли правый view центральным.

Бесконечный скролл с баннерами, или Как обойтись тремя вьюхами - 6

Результат работы реализации данного метода:

Управляем оффсетом во время прокрутки

Теперь прямо во время прокрутки будем менять 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.

Бесконечный скролл с баннерами, или Как обойтись тремя вьюхами - 7

Добавим метод 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. Для демонстрации работы воспользуемся первым способом:

Так… При резкой прокрутке сломалось центрирование.

Бесконечный скролл с баннерами, или Как обойтись тремя вьюхами - 8

Дело в том, что при сильной прокрутке игнорируется 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

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js