Défilement infini avec des bannières, ou comment faire avec trois vues


Chaque développeur de plates-formes mobiles est constamment confronté à une tâche qui ne peut être résolue d'une seule manière. Il y a toujours plusieurs façons, certaines rapides, d'autres compliquées, et chacune a ses propres avantages et inconvénients.

Le défilement sans fin / cyclique dans iOS ne peut pas être implémenté à l'aide d'outils standard, vous devez passer à différentes astuces. Dans cet article, je vais vous dire quelles options pour résoudre le problème se trouvent à la surface et quelle option nous avons finalement implémentée.

Tâche


Il était nécessaire de faire un défilement cyclique sans fin avec des éléments sous la forme d'une image préparée, d'un titre et d'un sous-titre. Entrée: l'image centrale est indentée de l'écran de 16,0 points. Sur les côtés de l'image centrale, des «oreilles» sortent. Et la distance entre les bannières est de 8,0 points.


Nous étudions ce qui a été fait par des collègues



Dodo - les bannières ne sont pas cycliques, la bannière centrale est toujours adjacente au bord gauche à une certaine distance.

Auto.ru - les bannières ne sont pas cycliques, si vous glissez, les bannières sont retournées pendant très longtemps.

Ozon - les bannières sont cycliques, mais elles ne peuvent pas être contrôlées par le toucher: une fois que la direction du balayage est déterminée, l'image ne peut plus être arrêtée.

Wildberries - les bannières ne sont pas cycliques, le centrage se produit, la longue animation de défilement est terminée.

Souhaits finaux:

  • Les bannières doivent être centrées.
  • Le défilement devrait se terminer sans attendre longtemps l'animation.
  • Gérabilité des bannières: il devrait être possible de contrôler l'animation de défilement avec votre doigt.

Options d'implémentation


Lorsqu'une nouvelle tâche surgit qui n'a pas encore été résolue, il convient de considérer les solutions et approches existantes.

UICollectionView


Agissant dans le front, vous pouvez venir à l'option de créer UICollectionView. Nous faisons le nombre d'éléments Int.maxet lors de l'initialisation nous montrons le milieu, et lors de l'appel de la méthode dans dataSource- func collectionView(UICollectionView, cellForItemAt: IndexPath) -> UICollectionViewCell. Nous retournerons l'élément correspondant, en supposant que l'élément zéro est le suivant Int.max / 2. Un tel monstre avec un tas de fonctionnalités, comme UICollectionView, est inapproprié à utiliser pour notre tâche simple.

UIScrollView et (n + 2) UIView


Il existe toujours une option dans laquelle UIScrollView est créée, absolument toutes les bannières y sont placées, et elle est ajoutée au début et à la fin par la bannière. Lorsque nous le terminons à la fin, nous modifions imperceptiblement le décalage pour l'utilisateur et revenons au premier élément. Et en faisant défiler en arrière, nous faisons le contraire. Par conséquent, avec un grand nombre d'éléments, un groupe de vues sera créé sans les réutiliser.


La source

Sa propre façon


Nous avons décidé de créer UIScrollView + trois UIView. Ces UIView seront réutilisés. Au moment du défilement, nous reviendrons contentOffsetà la bannière centrale et remplacerons le contenu des trois UIView. Et puis, vous devriez obtenir un composant léger qui ferme cette tâche.

Cependant, il est à craindre que l'usurpation de contenu pendant le défilement soit perceptible par l'utilisateur. Nous en apprenons plus lors de la mise en œuvre.

la mise en oeuvre


Préparation d'UIScrollView et de trois UIImageView


Créez un héritier UIView, placez-en UIScrollViewtrois dessus 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)
    }
}

Ajoutez l'implémentation de la méthode avec le paramètre scrollView:

  • decelerationRate- Ce paramètre indique la vitesse à laquelle l'animation de défilement ralentira. Dans notre cas, c'est mieux .fast.
  • showsHorizontalScrollIndicator - ce paramètre est responsable de l'affichage de la barre de défilement horizontale:

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

Après la configuration de base, nous pouvons faire la mise en page et le 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)
}

Ajoutez aux UIImageViewimages que nous allons extraire du site générateur d'images https://placeholder.com :

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

Le résultat des premières étapes préparatoires:


Centrer les images lors du défilement


Pour contrôler le défilement, nous utiliserons UIScrollViewDelegate. Nous setupdéfinissons le délégué pour la méthode UIScrollViewet définissons également contentInsetles première et dernière images pour avoir des retraits sur les côtés.

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

Nous créons extensionpour notre l' BannersViewune des méthodes. La méthode déléguée func scrollViewWillEndDraggingest appelée lorsque l'utilisateur arrête le défilement. Dans cette méthode, nous sommes intéressés targetContentOffset- c'est la variable qui est responsable du décalage de défilement final (le point auquel le défilement s'arrête).


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- c'est la distance à laquelle nous supposerons que la vue est centrale. Si un tiers de la largeur de l'image orange est affiché à l'écran, nous allons définir le décalage final de sorte que l'image orange soit au centre.


targetRightOffsetX - Ce point aidera à déterminer si la bonne vue est centrale.


Le résultat de la mise en œuvre de cette méthode:


Gérer le décalage lors du défilement


Maintenant, pendant le défilement, nous allons changer contentOffset, en retournant au centre de l'écran la vue centrale. Cela permettra à l'utilisateur de créer une illusion de défilement infini inaperçu.

Ajoutez une méthode déléguée func scrollViewDidScroll(_ scrollView: UIScrollView), elle est appelée lorsque contentOffsety change 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- c'est la distance à partir de laquelle nous déterminerons le besoin de déplacement contentOffset. Nous calculons le point pour rightItemView:, self.rightItemView.frame.maxX — gapaprès l'intersection dont nous allons changer contentOffset. Par exemple, s'il rightItemViewreste 100,0 points à faire défiler pour un affichage complet , alors nous contentOffsetreculons, de la largeur d'une bannière, en tenant compte de la distance entre les bannières (espacement), afin qu'elle soit centerItemViewen place rightItemView. De même, nous faisons pour leftItemView: nous calculons le point, après l'intersection duquel nous allons changer contentOffset.


Ajoutez une méthode func set(imageURLs: [URL])pour exposer les données à afficher à l'extérieur. Là, nous transférons une partie du code de setup.

Et nous ajouterons également une ligne afin que lorsque vous définissez le contenu, il soit centerItemViewimmédiatement centré. horizontalItemOffsetFromSuperViewnous l'avons déjà utilisé dans layoutSubviews, nous le mettons donc en constantes et nous le réutilisons.

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
}

Nous appellerons cette méthode à l'extérieur en UIViewController.viewDidAppear. Ou vous pouvez déplacer le premier alignement vers layoutSubviews, mais vérifiez que cela ne se fera que lors du changement du cadre de la vue entière. Pour démontrer le travail, nous utiliserons la première méthode:


Alors ... Avec un défilement pointu, le centrage s'est cassé.


Le fait est qu'avec un défilement puissant, il est ignoré targetContentOffset. Augmentons contentInset, après cela, tout fonctionne correctement. La vue centrale sera toujours centrée.

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


Nous remplaçons le contenu


La tâche consiste à contentOffsetremplacer en même temps le contenu de la vue à décalage . En faisant défiler vers la droite, l'image de droite deviendra centrale, la centrale deviendra gauche et la gauche - droite. 1 - 2 - 3 | 2 - 3 - 1.

Pour plus de commodité, créez un ViewModel:

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

Pour vérifier quel élément est maintenant au centre, ajoutez une variable à BannersViewet des variables avec du contenu pour chacune des vues:

    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- sur la base du currentCenterItemIndexcontenu pertinent rendement pour chaque vue. force unwrapet fatalici nous l'utilisons car le nombre d'éléments est ≥ 3 (si vous le souhaitez, vous pouvez ajouter une vérification à la méthode set).

Ajoutez des méthodes qui seront appelées si nécessaire pour modifier le contenu des vues:

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

Modifiez la méthode utilisée en externe pour exposer le contenu:

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

Et nous appellerons nextItem, et prevItemdans la méthode, le délégué lors du changement 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()
        }
    }

Augmentons le nombre de liens d'entrée vers les images à 5 (pour plus de commodité, il y en avait trois):


Étapes finales


Il reste à faire de la coutume UIViewau lieu d'une simple photo. Ce sera le titre, le sous-titre et l'image.

Étendre ViewModel:

struct BannersViewModel {
    let items: [Item]

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

Et écrivez une implémentation de bannière:

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

Remplacez UIImageViewet ViewModeldans 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)
    }

    .......

Résultat:


résultats


Faire un défilement en boucle sans fin avec des bannières s'est avéré être une tâche intéressante. Je suis sûr que chacun pourra tirer ses propres conclusions ou tirer des idées de notre décision de gérer avec seulement trois réutilisables UIView.

Encore une fois, nous étions convaincus que les solutions qui nous viennent à l'esprit en premier et les solutions que vous pouvez trouver sur Internet ne sont pas toujours optimales. Au début, nous avions peur que le remplacement du contenu lors du défilement entraîne un problème, mais tout fonctionnait correctement. N'ayez pas peur d'essayer vos approches si vous pensez que c'est juste. Pensez avec votre propre tête :).

All Articles