Bonjour, Habr! Je m'appelle Nikita, je travaille sur les SDK mobiles chez ABBYY et je m'occupe également du composant d'interface utilisateur pour numériser et visualiser facilement des documents de plusieurs pages sur un smartphone. Ce composant réduit le temps de développement d'applications basées sur
la technologie ABBYY Mobile Capture et se compose de plusieurs parties. Tout d'abord, un appareil photo pour numériser des documents; deuxièmement, un écran d'éditeur avec les résultats de la capture (c'est-à -dire des photos prises automatiquement) et un écran pour corriger les bordures du document.
Il suffit au développeur d'appeler quelques méthodes - et maintenant, dans son application, une caméra est déjà disponible qui numérise automatiquement les documents. Mais, en plus des caméras configurées, vous devez fournir aux clients un accès pratique aux résultats de l'analyse, c'est-à -dire prises automatiquement des photos. Et si le client scanne le contrat ou la charte, il peut y avoir beaucoup de telles photos.
Dans cet article, je parlerai des difficultés survenues lors de la mise en œuvre de l'écran de l'éditeur avec les résultats de la capture de documents. L'écran lui-même est un deux
UICollectionView
, je les appellerai grands et petits. Je vais omettre les possibilités de régler manuellement les bordures du document et d'autres travaux avec le document, et je me concentrerai sur les animations et les fonctionnalités de mise en page pendant le défilement. Ci-dessous sur GIF, vous pouvez voir ce qui s'est passé à la fin. Un lien vers le référentiel sera à la fin de l'article.
Comme références, je fais souvent attention aux applications du système Apple. Lorsque vous regardez attentivement les animations et autres solutions d'interface de leurs applications, vous commencez à admirer leur attitude attentive aux diverses bagatelles. Nous allons maintenant regarder l'application
Photos (iOS 12) comme référence. J'attirerai votre attention sur les fonctionnalités spécifiques de cette application, puis nous essaierons de les implémenter.
Nous allons couvrir la plupart des
UICollectionViewFlowLayout
personnalisation
UICollectionViewFlowLayout
, voir comment les techniques courantes telles que la parallaxe et le carrousel sont implémentées, et discuter des problèmes associés aux animations personnalisées lors de l'insertion et de la suppression de cellules.
Examen des fonctionnalités
Pour ajouter des détails, je décrirai quelles petites choses spécifiques m'ont plu dans l'application
Photos , puis je les implémenterai dans l'ordre approprié.
- Effet de parallaxe dans une grande collection
- Les éléments d'une petite collection sont centrés.
- Taille dynamique des articles dans une petite collection
- La logique de placement des éléments d'une petite cellule dépend non seulement du contentOffset, mais aussi des interactions de l'utilisateur
- Animations personnalisées pour déplacer et supprimer
- L'index de la cellule "active" n'est pas perdu lors du changement d'orientation
1. Parallaxe
Qu'est-ce que la parallaxe?
Le défilement de parallaxe est une technique en infographie où les images d'arrière-plan passent devant la caméra plus lentement que les images de premier plan, créant une illusion de profondeur dans une scène 2D et ajoutant au sentiment d'immersion dans l'expérience virtuelle.
Vous pouvez remarquer que lors du défilement, le cadre de la cellule se déplace plus rapidement que l'image qui s'y trouve.
Commençons! Créez une sous-classe de la cellule, placez-y l'UIImageView.
class PreviewCollectionViewCell: UICollectionViewCell { private(set) var imageView = UIImageView()​ override init(frame: CGRect) { super.init(frame: frame) addSubview(imageView) clipsToBounds = true imageView.snp.makeConstraints { $0.edges.equalToSuperview() } } }
Vous devez maintenant comprendre comment déplacer l'
imageView
, créant un effet de parallaxe. Pour ce faire, vous devez redéfinir le comportement des cellules lors du défilement. Apple:
Évitez de sous- UICollectionView
. La vue de collection a peu ou pas d’aspect propre. Au lieu de cela, il extrait toutes ses vues de votre objet de source de données et toutes les informations liées à la disposition de l'objet de disposition. Si vous essayez de disposer des éléments en trois dimensions, la bonne façon de le faire est d'implémenter une disposition personnalisée qui définit la transformation 3D de chaque cellule et de la visualiser de manière appropriée.
Ok, créons notre
objet de mise en page .
UICollectionView
a une propriété
collectionViewLayout
, Ă partir de laquelle il apprend des informations sur le positionnement des cellules.
UICollectionViewFlowLayout
est une implémentation de l'abrégé
UICollectionViewLayout
, qui est la propriété
collectionViewLayout
.
UICollectionViewLayout
attend que quelqu'un la sous-classe et fournisse le contenu approprié. UICollectionViewFlowLayout
est une classe concrète de UICollectionViewLayout
qui a tous ses quatre membres implémentés, de la manière dont les cellules seront organisées de manière en grille.
Créez une sous-classe de
UICollectionViewFlowLayout
et remplacez sa
layoutAttributesForElements(in:)
. La méthode renvoie un tableau de
UICollectionViewLayoutAttributes
, qui fournit des informations sur la façon d'afficher une cellule particulière.
La collection demande des attributs Ă chaque fois que
contentOffset
change, ainsi que lorsque la mise en page n'est pas valide. De plus, nous allons créer des attributs personnalisés en ajoutant la propriété
parallaxValue
, qui détermine combien le cadre de l'image est retardé par rapport au cadre de la cellule. Pour les sous-classes d'attributs, vous devez remplacer
NSCopiyng
pour elles. Apple:
Si vous sous-classe et implémentez des attributs de disposition personnalisés, vous devez également remplacer la méthode héritée isEqual: pour comparer les valeurs de vos propriétés. Dans iOS 7 et versions ultérieures, la vue de collection n'applique pas d'attributs de disposition si ces attributs n'ont pas changé. Il détermine si les attributs ont changé en comparant les anciens et les nouveaux objets d'attribut à l'aide de la méthode isEqual :. Étant donné que l'implémentation par défaut de cette méthode vérifie uniquement les propriétés existantes de cette classe, vous devez implémenter votre propre version de la méthode pour comparer les propriétés supplémentaires. Si vos propriétés personnalisées sont toutes égales, appelez super
et renvoyez la valeur résultante à la fin de votre implémentation.
Comment trouver
parallaxValue
? Calculons de combien vous avez besoin pour déplacer le centre de la cellule afin qu'elle se trouve au centre. Si cette distance est supérieure à la largeur de la cellule, martelez-la. Sinon, divisez cette distance par la largeur de la
cellule . Plus cette distance est proche de zéro, plus l'effet de parallaxe est faible.
class ParallaxLayoutAttributes: UICollectionViewLayoutAttributes { var parallaxValue: CGFloat? }​ class PreviewLayout: UICollectionViewFlowLayout { var offsetBetweenCells: CGFloat = 44​ override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { return true }​ override class var layoutAttributesClass: AnyClass { return ParallaxLayoutAttributes.self }​ override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { return super.layoutAttributesForElements(in: rect)? .compactMap { $0.copy() as? ParallaxLayoutAttributes } .compactMap(prepareAttributes) }​ private func prepareAttributes(attributes: ParallaxLayoutAttributes) -> ParallaxLayoutAttributes { guard let collectionView = self.collectionView else { return attributes }​ let width = itemSize.width let centerX = width / 2 let distanceToCenter = attributes.center.x - collectionView.contentOffset.x let relativeDistanceToCenter = (distanceToCenter - centerX) / width​ if abs(relativeDistanceToCenter) >= 1 { attributes.parallaxValue = nil attributes.transform = .identity } else { attributes.parallaxValue = relativeDistanceToCenter attributes.transform = CGAffineTransform(translationX: relativeDistanceToCenter * offsetBetweenCells, y: 0) } return attributes } }



Lorsque la collection reçoit les attributs nécessaires, les cellules les
appliquent . Ce comportement peut être remplacé dans la sous-classe de la cellule.
imageView
sur la valeur en fonction de
parallaxValue
. Cependant, pour que le décalage des images avec
contentMode == .aspectFit
fonctionne correctement, cela ne suffit pas, car le cadre d'image ne coĂŻncide pas avec le cadre
imageView
, par lequel le contenu est rogné lorsque
clipsToBounds == true
. Mettez un masque qui correspond Ă la taille de l'image avec le
contentMode
approprié et nous le mettrons à jour si nécessaire. Maintenant, tout fonctionne!
extension PreviewCollectionViewCell {​ override func layoutSubviews() {​ super.layoutSubviews() guard let imageSize = imageView.image?.size else { return } let imageRect = AVMakeRect(aspectRatio: imageSize, insideRect: bounds)​ let path = UIBezierPath(rect: imageRect) let shapeLayer = CAShapeLayer() shapeLayer.path = path.cgPath layer.mask = shapeLayer } override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) {​ guard let attrs = layoutAttributes as? ParallaxLayoutAttributes else { return super.apply(layoutAttributes) } let parallaxValue = attrs.parallaxValue ?? 0 let transition = -(bounds.width * 0.3 * parallaxValue) imageView.transform = CGAffineTransform(translationX: transition, y: .zero) } }
2. Les éléments d'une petite collection sont centrés

Ici, tout est très simple. Cet effet peut être obtenu en plaçant de grands
inset
Ă gauche et Ă droite. Lors du dĂ©filement vers la droite / gauche, il est nĂ©cessaire de commencer Ă
bouncing
uniquement lorsque la dernière cellule a quitté le contenu visible. Autrement dit, le contenu visible doit être égal à la taille de la cellule.
extension ThumbnailFlowLayout {​ var farInset: CGFloat { guard let collection = collectionView else { return .zero } return (collection.bounds.width - itemSize.width) / 2 } var insets: UIEdgeInsets { UIEdgeInsets(top: .zero, left: farInset, bottom: .zero, right: farInset) }​ override func prepare() { collectionView?.contentInset = insets super.prepare() } }

En savoir plus sur le centrage: lorsque la collection a fini de défiler, la mise en page demande un
contentOffset
pour s'arrĂŞter. Pour ce faire, remplacez
targetContentOffset(forProposedContentOffset:withScrollingVelocity:)
. Apple:
Si vous souhaitez que le comportement de défilement s'aligne sur des limites spécifiques, vous pouvez remplacer cette méthode et l'utiliser pour modifier le point auquel s'arrêter. Par exemple, vous pouvez utiliser cette méthode pour toujours arrêter le défilement sur une limite entre les éléments, par opposition à l'arrêt au milieu d'un élément.
Pour tout rendre beau, nous nous arrĂŞterons
toujours au centre de la cellule la plus proche. Le calcul du centre de la cellule la plus proche est une tâche plutôt triviale, mais vous devez être prudent et considérer le
contentInset
.
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint { guard let collection = collectionView else { return super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity) } let cellWithSpacing = itemSize.width + config.distanceBetween let relative = (proposedContentOffset.x + collection.contentInset.left) / cellWithSpacing let leftIndex = max(0, floor(relative)) let rightIndex = min(ceil(relative), CGFloat(itemsCount)) let leftCenter = leftIndex * cellWithSpacing - collection.contentInset.left let rightCenter = rightIndex * cellWithSpacing - collection.contentInset.left​ if abs(leftCenter - proposedContentOffset.x) < abs(rightCenter - proposedContentOffset.x) { return CGPoint(x: leftCenter, y: proposedContentOffset.y) } else { return CGPoint(x: rightCenter, y: proposedContentOffset.y) } }
3. La taille dynamique des éléments d'une petite collection
Si vous faites défiler une grande collection, le
contentOffset
change pour une petite
contentOffset
. De plus, la cellule centrale d'une petite collection n'est pas aussi grande que les autres. Les cellules latérales ont une taille fixe et la cellule centrale coïncide avec le rapport d'aspect de l'image qu'elle contient.

Vous pouvez utiliser la même technique que dans le cas de la parallaxe. Créons un
UICollectionViewFlowLayout
personnalisé pour une petite collection et redéfinissons
prepareAttributes(attributes:
donné que la logique de mise en page de la petite collection sera compliquée, nous créerons une entité distincte pour stocker et calculer la géométrie des cellules.
struct Cell { let indexPath: IndexPath​ let dims: Dimensions let state: State​ func updated(new state: State) -> Cell { return Cell(indexPath: indexPath, dims: dims, state: state) } }​ extension Cell { struct Dimensions {​ let defaultSize: CGSize let aspectRatio: CGFloat let inset: CGFloat let insetAsExpanded: CGFloat }​ struct State {​ let expanding: CGFloat​ static var `default`: State { State(expanding: .zero) } } }
UICollectionViewFlowLayout
a une propriété
collectionViewContentSize
qui détermine la taille de la zone qui peut défiler. Afin de ne pas compliquer notre vie, laissons-la constante, quelle que soit la taille de la cellule centrale. Pour la géométrie correcte pour chaque cellule, vous devez connaître l'
aspectRatio
image et l'éloignement du centre de la cellule de
contentOffset
. Plus la cellule est proche, plus sa
size.width / size.height
de l'
aspectRatio
. Lorsque vous redimensionnez une cellule spécifique, déplacez les cellules restantes (à droite et à gauche) à l'aide de
affineTransform
. Il s'avère que pour calculer la géométrie d'une cellule particulière, vous devez connaître les attributs des voisins (visibles).
extension Cell {​ func attributes(from layout: ThumbnailLayout, with sideCells: [Cell]) -> UICollectionViewLayoutAttributes? {​ let attributes = layout.layoutAttributesForItem(at: indexPath)​ attributes?.size = size attributes?.center = center​ let translate = sideCells.reduce(0) { (current, cell) -> CGFloat in if indexPath < cell.indexPath { return current - cell.additionalWidth / 2 } if indexPath > cell.indexPath { return current + cell.additionalWidth / 2 } return current } attributes?.transform = CGAffineTransform(translationX: translate, y: .zero)​ return attributes } var additionalWidth: CGFloat { (dims.defaultSize.height * dims.aspectRatio - dims.defaultSize.width) * state.expanding } var size: CGSize { CGSize(width: dims.defaultSize.width + additionalWidth, height: dims.defaultSize.height) } var center: CGPoint { CGPoint(x: CGFloat(indexPath.row) * (dims.defaultSize.width + dims.inset) + dims.defaultSize.width / 2, y: dims.defaultSize.height / 2) } }
state.expanding
est considéré comme la même chose que
parallaxValue
.
func cell(for index: IndexPath, offsetX: CGFloat) -> Cell {​ let cell = Cell( indexPath: index, dims: Cell.Dimensions( defaultSize: itemSize, aspectRatio: dataSource(index.row), inset: config.distanceBetween, insetAsExpanded: config.distanceBetweenFocused), state: .default)​ guard let attribute = cell.attributes(from: self, with: []) else { return cell }​ let cellOffset = attribute.center.x - itemSize.width / 2 let widthWithOffset = itemSize.width + config.distanceBetween if abs(cellOffset - offsetX) < widthWithOffset { let expanding = 1 - abs(cellOffset - offsetX) / widthWithOffset return cell.updated(by: .expand(expanding)) } return cell }​ override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { return (0 ..< itemsCount) .map { IndexPath(row: $0, section: 0) } .map { cell(for: $0, offsetX: offsetWithoutInsets.x) } .compactMap { $0.attributes(from: self, with: cells) } }
4. La logique de placement des éléments d'une petite cellule dépend non seulement du contentOffset, mais aussi des interactions des utilisateurs
Lorsqu'un utilisateur fait défiler une petite collection, toutes les cellules ont la même taille. Lorsque vous faites défiler une grande collection, ce n'est pas le cas. (
voir les gifs 3 et 5 ). Écrivons un animateur qui mettra à jour les propriétés de la disposition
ThumbnailLayout
. L'animateur stockera
DisplayLink
en lui-même et appellera le bloc 60 fois par seconde, donnant accès à la progression actuelle. Il est facile de
easing functions
diverses
easing functions
à l'animateur. La mise en œuvre peut être consultée sur le github au lien à la fin du post.
Entrons la propriété
ThumbnailLayout
dans
ThumbnailLayout
, par laquelle l'
expanding
toutes les
Cell
sera multipliée. Il s'avère que
expandingRate
indique dans quelle mesure l'
aspectRatio
image particulière affectera sa taille si elle devient centrée. Avec
expandingRate == 0
toutes les cellules auront la même taille. Au début du défilement d'une petite collection, nous exécuterons un animateur qui définit l'
expandingRate
à 0, et à la fin du défilement, vice versa, à 1. En fait, lors de la mise à jour de la disposition, la taille de la cellule centrale et des cellules latérales changera. Aucun
contentOffset
avec
contentOffset
et secousses!
class ScrollAnimation: NSObject {​ enum `Type` { case begin case end }​ let type: Type​ func run(completion: @escaping () -> Void) { let toValue: CGFloat = self.type == .begin ? 0 : 1 let currentExpanding = thumbnails.config.expandingRate let duration = TimeInterval(0.15 * abs(currentExpanding - toValue))​ let animator = Animator(onProgress: { current, _ in let rate = currentExpanding + (toValue - currentExpanding) * current self.thumbnails.config.expandingRate = rate self.thumbnails.invalidateLayout() }, easing: .easeInOut)​ animator.animate(duration: duration) { _ in completion() } } }
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { if scrollView == thumbnails.collectionView { handle(event: .beginScrolling)
5. Animations personnalisées pour déplacer et supprimer
Il existe de nombreux articles expliquant comment créer des animations personnalisées pour mettre à jour les cellules, mais dans notre cas, ils ne nous aideront pas. Les articles et les didacticiels décrivent comment remplacer les attributs d'une cellule mise à jour. Dans notre cas, la modification de la disposition de la cellule supprimée provoque des effets secondaires - l'
expanding
cellule voisine, qui a tendance à remplacer celle supprimée lors de l'animation, change.
La mise Ă jour du contenu dans un
UICollectionViewFlowLayout
fonctionne comme suit. Après avoir supprimé / ajouté une cellule, la méthode
prepare(forCollectionViewUpdates:)
démarre, donnant un tableau de
UICollectionViewUpdateItem
, qui nous indique à quelles cellules les index ont été mis à jour / supprimés / ajoutés. Ensuite, la mise en page appellera un groupe de méthodes
finalLayoutAttributesForDisappearingItem(at:) initialLayoutAttributesForAppearingDecorationElement(ofKind:at:)
et leurs amis pour la décoration / vues supplémentaires. Lorsque les attributs des données mises à jour sont reçus,
finalizeCollectionViewUpdates
est appelé. Apple:
La vue de collection appelle cette méthode comme dernière étape avant de procéder à l'animation des modifications. Cette méthode est appelée dans le bloc d'animation utilisé pour effectuer toutes les animations d'insertion, de suppression et de déplacement afin que vous puissiez créer des animations supplémentaires à l'aide de cette méthode si nécessaire. Sinon, vous pouvez l'utiliser pour effectuer des tâches de dernière minute associées à la gestion des informations d'état de votre objet de présentation.
Le problème est que nous ne pouvons spécialiser les attributs que pour la cellule
mise Ă
jour , et nous devons les changer pour toutes les cellules, et de différentes manières. La nouvelle cellule centrale doit changer d'
aspectRatio
et les cellules latérales doivent se
transform
.

Après avoir examiné le fonctionnement de l'animation par défaut des cellules de collection lors de la suppression / insertion, il est devenu connu que les cellules de la couche dans
CABasicAnimation
contiennent
CABasicAnimation
, qui peut y être modifiée si vous souhaitez personnaliser l'animation pour les cellules restantes. Les choses ont empiré lorsque les journaux ont montré qu'entre
performBatchUpdates
et
prepare(forCollectionViewUpdates:)
prepareAttributes(attributes:)
prepare(forCollectionViewUpdates:)
est appelé, et il peut déjà y avoir le mauvais nombre de cellules, bien que
collectionViewUpdates
n'ait pas encore commencé, il est très difficile de maintenir et de comprendre cela. Que peut-on faire à ce sujet? Vous pouvez désactiver ces animations intégrées!
final override func prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem]) { super.prepare(forCollectionViewUpdates: updateItems) CATransaction.begin() CATransaction.setDisableActions(true) }​ final override func finalizeCollectionViewUpdates() { CATransaction.commit() }
Armés des animateurs déjà écrits, nous ferons toutes les animations nécessaires sur demande de suppression, et nous lancerons la mise à jour
dataSource
Ă la fin de l'animation. Ainsi, nous simplifierons l'animation de la collection lors de la mise Ă jour, puisque nous contrĂ´lons nous-mĂŞmes quand le nombre de cellules va changer.
func delete( at indexPath: IndexPath, dataSourceUpdate: @escaping () -> Void, completion: (() -> Void)?) {​ DeleteAnimation(thumbnails: thumbnails, preview: preview, index: indexPath).run { let previousCount = self.thumbnails.itemsCount if previousCount == indexPath.row + 1 { self.activeIndex = previousCount - 1 } dataSourceUpdate() self.thumbnails.collectionView?.deleteItems(at: [indexPath]) self.preview.collectionView?.deleteItems(at: [indexPath]) completion?() } }
Comment ces animations fonctionneront-elles? Dans
ThumbnailLayout
stockons des brochures facultatives qui mettent à jour la géométrie de cellules spécifiques.
class ThumbnailLayout {​ typealias CellUpdate = (Cell) -> Cell var updates: [IndexPath: CellUpdate] = [:]
Avec un tel outil, vous pouvez tout faire avec la géométrie des cellules, lancer des mises à jour pendant le travail de l'animateur et les supprimer dans le compliment. Il y a aussi la possibilité de combiner les mises à jour.
updates[index] = newUpdate(updates[index])
Le code d'animation de suppression est plutĂ´t lourd; il se trouve dans le fichier
DeleteAnimation.swift du référentiel. L'animation du changement de focus entre les cellules est implémentée de la même manière.

6. L'index de la cellule "active" n'est pas perdu lors du changement d'orientation
scrollViewDidScroll(_ scrollView:)
est appelé même si vous
scrollViewDidScroll(_ scrollView:)
simplement une valeur dans
contentOffset
, ainsi que lorsque vous changez l'orientation. Lorsque le défilement de deux collections est synchronisé, certains problèmes peuvent survenir lors des mises à jour de la mise en page. L'astuce suivante aide: lors des mises à jour de la mise en page, vous pouvez définir
scrollView.delegate
sur
nil
.
extension ScrollSynchronizer {​ private func bind() { preview.collectionView?.delegate = self thumbnails.collectionView?.delegate = self }​ private func unbind() { preview.collectionView?.delegate = nil thumbnails.collectionView?.delegate = nil } }
Lors de la mise Ă jour de la taille des cellules au moment du changement d'orientation, cela ressemblera Ă ceci:
extension PhotosViewController {​ override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator)​ contentView.synchronizer.unbind() coordinator.animate(alongsideTransition: nil) { [weak self] _ in self?.contentView.synchronizer.bind() } } }
Afin de ne pas perdre le
contentOffset
souhaité lors de la modification de l'orientation, vous pouvez mettre à jour
targetIndexPath
dans
scrollView.delegate
. Lorsque vous modifiez l'orientation, la mise en page sera désactivée si vous remplacez
shouldInvalidateLayout(forBoundsChange:)
. Lors du changement des
bounds
mise en page demandera de clarifier
contentOffset
, pour le clarifier, vous devez redéfinir
targetContentOffset(forProposedContentOffset:)
. Apple:
Pendant les mises à jour de mise en page ou lors de la transition entre les mises en page, la vue de collection appelle cette méthode pour vous donner la possibilité de modifier le décalage de contenu proposé à utiliser à la fin de l'animation. Vous pouvez remplacer cette méthode si les animations ou la transition peuvent entraîner le positionnement des éléments d'une manière qui n'est pas optimale pour votre conception.
La vue de collection appelle cette méthode après avoir appelé les méthodes prepare()
et collectionViewContentSize
.
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint { let targetOffset = super.targetContentOffset(forProposedContentOffset: proposedContentOffset) guard let layoutHandler = layoutHandler else { return targetOffset } let offset = CGFloat(layoutHandler.targetIndex) / CGFloat(itemsCount) return CGPoint( x: collectionViewContentSize.width * offset - farInset, y: targetOffset.y) }
, !
github.com/YetAnotherRzmn/PhotosApp