Rolagem infinita com banners ou Como fazer com três visualizações


Cada desenvolvedor de plataformas móveis é constantemente confrontado com uma tarefa que não pode ser resolvida de uma única maneira. Sempre existem várias maneiras, algumas rápidas, outras complicadas, e cada uma tem suas próprias vantagens e desvantagens.

A rolagem interminável / cíclica no iOS não pode ser implementada usando ferramentas padrão; você precisa fazer truques diferentes. Neste artigo, mostrarei quais opções para resolver o problema estão na superfície e quais opções foram implementadas.

Tarefa


Era necessário fazer uma rolagem cíclica sem fim com elementos na forma de uma imagem, título e legenda preparados. Entrada: a imagem central é recuada da tela em 16,0 pontos. Nas laterais da imagem central, as “orelhas” se destacam. E a distância entre os banners é de 8,0 pontos.


Estudamos o que foi feito por colegas



Dodo - banners não são cíclicos, o banner central é sempre adjacente à borda esquerda a uma certa distância.

Auto.ru - os banners não são cíclicos; se você deslizar, os banners são exibidos por um longo período de tempo.

Os banners de ozônio são cíclicos, mas não podem ser controlados pelo toque: depois que a direção do furto é determinada, a imagem não pode mais ser interrompida.

Wildberries - banners não são cíclicos, ocorre centralização, conclusão longa da animação de rolagem.

Desejos finais:

  • As faixas devem estar centralizadas.
  • A rolagem deve terminar sem uma longa espera pela animação.
  • Gerenciamento de banners: deve haver a capacidade de controlar a animação de rolagem com o dedo.

Opções de implementação


Quando surge uma nova tarefa que ainda não foi resolvida, vale a pena considerar as soluções e abordagens existentes.

UICollectionView


Atuando na testa, você pode optar por criar UICollectionView. Fazemos o número de elementos Int.maxe, durante a inicialização, mostramos o meio e, ao chamar o método em dataSource- func collectionView(UICollectionView, cellForItemAt: IndexPath) -> UICollectionViewCell. Retornaremos o elemento correspondente, assumindo que o elemento zero seja esse Int.max / 2. Um monstro com muitos recursos, como UICollectionView, é inapropriado para nossa simples tarefa.

UIScrollView e (n + 2) UIView


Ainda existe uma opção na qual o UIScrollView é criado, absolutamente todos os banners são colocados nele e são adicionados ao início e ao fim pelo banner. Quando o terminamos, alteramos o deslocamento imperceptivelmente para o usuário e retornamos ao primeiro elemento. E ao rolar para trás, fazemos o oposto. Como resultado, com um grande número de elementos, um monte de visualizações será criado sem reutilizá-las.


Fonte

Próprio caminho


Decidimos fazer o UIScrollView + três UIView. Estes UIView serão reutilizados. No momento da rolagem, retornaremos contentOffsetao banner central e substituiremos o conteúdo dos três UIView. E então você deve obter um componente leve que feche esta tarefa.

No entanto, existe a preocupação de que a falsificação de conteúdo durante a rolagem seja perceptível para o usuário. Aprendemos sobre isso durante a implementação.

Implementação


Preparando o UIScrollView e três UIImageView


Crie um herdeiro UIView, coloque UIScrollViewtrês nele 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)
    }
}

Adicione a implementação do método com a configuração scrollView:

  • decelerationRate- Este parâmetro indica a velocidade da animação da rolagem. No nosso caso, é melhor .fast.
  • showsHorizontalScrollIndicator - este parâmetro é responsável por exibir a barra de rolagem horizontal:

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

Após a configuração básica, podemos fazer o layout e o posicionamento 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)
}

Adicione às UIImageViewimagens que acessaremos do site gerador de imagens https://placeholder.com :

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

O resultado das primeiras etapas preparatórias:


Centralizar imagens ao rolar


Para controlar o pergaminho, usaremos UIScrollViewDelegate. Definimos setupo delegado para o método UIScrollViewe também definimos contentInseta primeira e a última imagens com recuos nas laterais.

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

Criamos extensionpara o nosso BannersViewum dos métodos. O método delegado func scrollViewWillEndDraggingé chamado quando o usuário para de rolar. Neste método, estamos interessados targetContentOffset- essa é a variável responsável pelo deslocamento final da rolagem (o ponto em que a rolagem para).


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- esta é a distância em que assumiremos que a visão é central. Se um terço da largura da imagem laranja for exibido na tela, definiremos o deslocamento final para que a imagem laranja fique no centro.


targetRightOffsetX - Este ponto ajudará a determinar se a visão correta é central.


O resultado da implementação deste método:


Gerenciar deslocamento ao rolar


Agora, logo durante a rolagem, mudaremos contentOffset, retornando ao centro da tela a vista central. Isso permitirá que o usuário crie uma ilusão de rolagem infinita despercebida.

Adicione um método delegado func scrollViewDidScroll(_ scrollView: UIScrollView), chamado quando contentOffsety for alterado 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- esta é a distância a partir da qual determinaremos a necessidade de deslocamento contentOffset. Calculamos o ponto para rightItemView:, self.rightItemView.frame.maxX — gapapós a interseção da qual mudaremos contentOffset. Por exemplo, se rightItemView100.0 pontos ainda tiverem que ser rolados para exibição total , então contentOffsetretrocederemos, pela largura de uma faixa, levando em consideração a distância entre as faixas (espaçamento), para que ela fique centerItemViewno lugar rightItemView. Da mesma forma, fazemos o seguinte leftItemView: calculamos o ponto, após a interseção da qual mudaremos contentOffset.


Adicione um método func set(imageURLs: [URL])para expor os dados para exibição externa. Aí transferimos parte do código de setup.

Além disso, adicionaremos uma linha para que, quando você definir o conteúdo, ele seja centerItemViewimediatamente centralizado. horizontalItemOffsetFromSuperViewcomo já usamos layoutSubviews, colocamos em constantes e usamos novamente.

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
}

Vamos chamar esse método de fora para dentro UIViewController.viewDidAppear. Ou você pode mover o primeiro alinhamento para layoutSubviews, mas verifique se isso será feito apenas ao alterar o quadro de toda a exibição. Para demonstrar o trabalho, usaremos o primeiro método:


Então ... Com um rolo afiado, a centralização quebrou.


O fato é que, com rolagem forte, é ignorado targetContentOffset. Vamos aumentar contentInset, depois disso tudo funciona corretamente. A vista central será sempre centralizada.

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


Substituímos o conteúdo


A tarefa é contentOffsetsubstituir o conteúdo da visualização em deslocamento ao mesmo tempo. Ao rolar para a direita, a imagem direita se tornará central, o centro ficará à esquerda e a esquerda ficará à direita. 1 - 2 - 3 2 - 3 - 1.

Por conveniência, crie um ViewModel:

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

Para verificar qual elemento está agora no centro, adicione uma variável BannersViewe variáveis ​​com conteúdo para cada uma das visualizações:

    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- com base em currentCenterItemIndexconteúdo relevante de retorno para cada vista. force unwrape fatalaqui o usamos porque o número de elementos é ≥ 3 (se desejar, você pode adicionar uma verificação ao método set).

Adicione métodos que serão chamados, se necessário, para alterar o conteúdo das visualizações:

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

Altere o método usado externamente para expor o conteúdo:

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

E chamaremos nextItem, e prevItemno método, o delegado ao alterar 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()
        }
    }

Vamos aumentar o número de links de entrada para imagens para 5 (por conveniência, foram três):


Etapas finais


Resta customizar em UIViewvez de uma imagem simples. Este será o título, legenda e imagem.

Estender ViewModel:

struct BannersViewModel {
    let items: [Item]

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

E escreva uma implementação de banner:

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

Substitua UIImageViewe ViewModelentre 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)
    }

    .......

Resultado:


achados


Fazer um rolo interminável com faixas acabou sendo uma tarefa interessante. Estou certo de que todos serão capazes de tirar suas próprias conclusões ou tirar idéias de nossa decisão de administrar com apenas três reutilizáveis UIView.

Mais uma vez, estávamos convencidos de que as soluções que vêm à mente primeiro e as soluções que você pode encontrar na Internet nem sempre são ótimas. No início, tínhamos medo de que a substituição do conteúdo durante a rolagem levasse a um problema, mas tudo funcionava sem problemas. Não tenha medo de tentar suas abordagens, se você acha que isso é certo. Pense com sua própria cabeça :).

All Articles