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.max
et 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 sourceSa 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 UIScrollView
trois 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
: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 UIImageView
images 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 setup
définissons le délégué pour la méthode UIScrollView
et définissons également contentInset
les 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 extension
pour notre l' BannersView
une des méthodes. La méthode déléguée func scrollViewWillEndDragging
est 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 contentOffset
y 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 — gap
après l'intersection dont nous allons changer contentOffset
. Par exemple, s'il rightItemView
reste 100,0 points à faire défiler pour un affichage complet , alors nous contentOffset
reculons, de la largeur d'une bannière, en tenant compte de la distance entre les bannières (espacement), afin qu'elle soit centerItemView
en 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 centerItemView
immédiatement centré. horizontalItemOffsetFromSuperView
nous l'avons déjà utilisé dans layoutSubviews
, nous le mettons donc en constantes et nous le réutilisons.func set(imageURLs: [URL]) {
let imageViews = [self.leftItemView, self.centerItemView, self.rightItemView]
imageViews.enumerated().forEach { key, view in
view.setImage(with: imageURLs[key])
}
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 à contentOffset
remplacer 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 {
let items: [URL] = ImageURLFactory.makeImageURLS()
}
Pour vérifier quel élément est maintenant au centre, ajoutez une variable à BannersView
et 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 currentCenterItemIndex
contenu pertinent rendement pour chaque vue. force unwrap
et fatal
ici 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 prevItem
dans 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 UIView
au 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 UIImageView
et ViewModel
dans 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 :).