company_banner

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


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

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

    Задача


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


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



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

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

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

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

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

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

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


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

    UICollectionView


    Действуя в лоб, можно прийти к варианту с созданием UICollectionView. Делаем количество элементов Int.max и при инициализации показываем середину, а при вызове метода в dataSourcefunc 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.

    Еще раз мы убедились, что решения, которые приходят в голову первыми, и решения которые вы можете найти в интернете, не всегда являются оптимальными. Сначала мы опасались, что подменять контент во время прокрутки приведёт к проблеме, но всё работает гладко. Не бойтесь пробовать свои подходы, если считаете, что это правильно. Думайте своей головой :).
    Юла
    Сlassified 2.0

    Комментарии 7

      0
      спасибо конечно, но все же, чем не устроил UICollectionView?
      Писать столько кода это типа нецелесообразно?
        0

        По количеству кода это примерно тоже самое, а то и меньше, чем делать используя UICollectionView. Ячейку всё равно бы пришлось писать, реализовывать методы делегата и датасорс. А так же центровку из коробки UICollectionView не предоставляет, соответственно код аналогичный scrollViewWillEndDragging необходимо было бы реализовывать.
        Под капотом UICollectionView – очень много функционала, который здесь не нужен.
        Разработанный компонент используется и так в нагруженной ленте и почему бы не сэкономить ресурсы пользователя.

          0
          Если стоит задача сэкономить ресурсы пользователя, то почему бы не нырнуть на один уровень вниз и заюзать CAScrollLayer? Тем более что все равно используется layoutSubviews а не констрейнты.
            0

            Задачи сэкономить ресурсы не стоит. Это скорей положительный сайд эффект.
            Есть еще множество других способов, можно например на Metal, всё это реализовать.Стоит соблюдать баланс.
            Текущая реализация не является универсальной. Я делюсь опытом и возможно кому то такой вариант подойдет на 100%, а кто то узнает как можно центрировать UIView в UIScrollView.
            И еще один момент – я хотел показать, что не стоит сразу хватать первое решение, которое приходит в голову или которое можно найти в интернете, всегда можно попробовать свои подходы, которые отлично ложатся на поставленную задачу.

        0
        В Андроиде есть RecyclerView, который делает то же самое.
          +2
          В iOS есть UICollectionView, который делает то же самое. Но его решили не использовать)
          0
          Ещё можно было реализацию упростить, сделав класс-наследник UIScrollView и внутри него реализовав реюз «ячеек» по типу той же UITableView (когда есть кэш ячеек, есть prepareToReuse и всё такое).

          Тогда бы не было этих весёлых плясок с перемещением ячеек внутри scrollViewDidScroll:, т.к. можно было бы делать лэйаут всего видимого на экране прямо внутри layoutSubviews скролл-вью, как, насколько я понимаю, и подразумевалось писавшими UIKit людьми: определяешь видимый сейчас bounds, определяешь, ячейки с какими индексами должны быть видимы в этих bounds, создаешь / берёшь из кэша вьюхи для ещё не показанных ячеек, лэйаутишь их, а те, которые становятся невидимыми, убираешь с супервью и кладёшь в кэш. Снаружи остаётся только отдавать данные для наполнения этого скролл-вью и правильно реализовать targetContentOffset.

          Такой вариант позволил бы не хардкодить количество ячеек на экране, а показывать столько, сколько влезает в ширину экрана (ваша реализация ведь совсем не подходит для iPad, где неплохо бы показать не один центральный и края двух соседних, а, допустим, три целиком и края двух). Ну и в ситуацию с «сломалось центрирование» вы бы так не попали.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое