Hola Habr! Mi nombre es Nikita, trabajo en SDKs móviles en ABBYY, y también trato con el componente UI para escanear y ver convenientemente documentos de varias páginas en un teléfono inteligente. Este componente reduce el tiempo para desarrollar aplicaciones basadas en la
tecnología ABBYY Mobile Capture y consta de varias partes. En primer lugar, una cámara para escanear documentos; en segundo lugar, una pantalla del editor con los resultados de la captura (es decir, fotos tomadas automáticamente) y una pantalla para corregir los bordes del documento.
Es suficiente para el desarrollador llamar a un par de métodos, y ahora en su aplicación ya hay una cámara disponible que escanea automáticamente los documentos. Pero, además de las cámaras configuradas, debe proporcionar a los clientes un acceso conveniente a los resultados del escaneo, es decir, fotos tomadas automáticamente Y si el cliente escanea el contrato o la carta, entonces puede haber muchas de esas fotos.
En esta publicación hablaré sobre las dificultades que surgieron durante la implementación de la pantalla del editor con los resultados de la captura de documentos. La pantalla en sí es un
UICollectionView
dos, los llamaré grandes y pequeños. Omitiré las posibilidades de ajustar manualmente los bordes del documento y otros trabajos con el documento, y me centraré en las animaciones y las características de diseño durante el desplazamiento. A continuación, en GIF, puedes ver lo que sucedió al final. Un enlace al repositorio estará al final del artículo.
Como referencias, a menudo presto atención a las aplicaciones del sistema Apple. Cuando observa detenidamente las animaciones y otras soluciones de interfaz de sus aplicaciones, comienza a admirar su actitud atenta hacia los pequeños detalles. Ahora veremos la aplicación
Fotos (iOS 12) como referencia. Le llamaré la atención sobre las características específicas de esta aplicación y luego intentaremos implementarlas.
UICollectionViewFlowLayout
mayoría de las
UICollectionViewFlowLayout
personalización
UICollectionViewFlowLayout
, veremos cómo se implementan técnicas comunes como paralaje y carrusel, y discutiremos los problemas asociados con las animaciones personalizadas al insertar y eliminar celdas.
Revisión de características
Para agregar detalles, describiré qué pequeñas cosas específicas me complacieron en la aplicación
Fotos , y luego las implementaré en el orden apropiado.
- Efecto de paralaje en una gran colección.
- Los elementos de una pequeña colección están centrados.
- Tamaño dinámico de artículos en una pequeña colección.
- La lógica de colocar los elementos de una celda pequeña depende no solo del contentOffset, sino también de las interacciones del usuario
- Animaciones personalizadas para mover y eliminar
- El índice de la celda "activa" no se pierde al cambiar de orientación
1. Parallax
¿Qué es el paralaje?
El desplazamiento en paralaje es una técnica en gráficos de computadora donde las imágenes de fondo pasan más allá de la cámara más lentamente que las imágenes en primer plano, creando una ilusión de profundidad en una escena 2D y aumentando la sensación de inmersión en la experiencia virtual.
Puede notar que al desplazarse, el marco de la celda se mueve más rápido que la imagen que contiene.
¡Empecemos! Cree una subclase de la celda, coloque el UIImageView en ella.
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() } } }
Ahora debe comprender cómo cambiar la
imageView
, creando un efecto de paralaje. Para hacer esto, debe redefinir el comportamiento de las celdas durante el desplazamiento. Manzana:
Evite subclasificar UICollectionView
. La vista de colección tiene poca o ninguna apariencia propia. En su lugar, extrae todas sus vistas del objeto de origen de datos y toda la información relacionada con el diseño del objeto de diseño. Si está intentando diseñar elementos en tres dimensiones, la forma correcta de hacerlo es implementar un diseño personalizado que establezca la transformación 3D de cada celda y se visualice de manera adecuada.
Ok, creemos nuestro
objeto de diseño .
UICollectionView
tiene una propiedad
collectionViewLayout
, de la que aprende información sobre el posicionamiento de las celdas.
UICollectionViewFlowLayout
es una implementación del resumen
UICollectionViewLayout
, que es la propiedad
collectionViewLayout
.
UICollectionViewLayout
está esperando que alguien lo subclasifique y proporcione el contenido apropiado. UICollectionViewFlowLayout
es una clase concreta de UICollectionViewLayout
que tiene implementados sus cuatro miembros, de la forma en que las celdas se organizarán de manera cuadriculada.
Cree una subclase de
UICollectionViewFlowLayout
y anule su
layoutAttributesForElements(in:)
. El método devuelve una matriz de
UICollectionViewLayoutAttributes
, que proporciona información sobre cómo mostrar una celda en particular.
La colección solicita atributos cada vez que cambia
contentOffset
, así como cuando el diseño no es válido. Además, crearemos atributos personalizados agregando la propiedad
parallaxValue
, que determina cuánto se retrasa el marco de la imagen desde el marco de la celda. Para las subclases de atributos, debe anular
NSCopiyng
para ellas. Manzana:
Si subclasifica e implementa cualquier atributo de diseño personalizado, también debe anular el método hereEqual: para comparar los valores de sus propiedades. En iOS 7 y versiones posteriores, la vista de colección no aplica atributos de diseño si esos atributos no han cambiado. Determina si los atributos han cambiado comparando los objetos de atributo antiguos y nuevos utilizando el método isEqual: Debido a que la implementación predeterminada de este método verifica solo las propiedades existentes de esta clase, debe implementar su propia versión del método para comparar cualquier propiedad adicional. Si sus propiedades personalizadas son todas iguales, llame a super
y devuelva el valor resultante al final de su implementación.
¿Cómo averiguar el valor de
parallaxValue
? Calculemos cuánto necesita mover el centro de la celda para que quede en el centro. Si esta distancia es mayor que el ancho de la celda, entonces martillela. De lo contrario, divida esta distancia por el ancho de la
celda . Cuanto más cerca esté esta distancia a cero, más débil será el efecto de paralaje.
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 } }



Cuando la colección recibe los atributos necesarios, las celdas los
aplican . Este comportamiento puede anularse en la subclase de la celda. Cambiemos
imageView
en el valor dependiendo de
parallaxValue
. Sin embargo, para que el cambio de imágenes con
contentMode == .aspectFit
funcione correctamente, esto no es suficiente, porque el marco de la imagen no coincide con el marco de
imageView
, por el cual el contenido se recorta cuando
clipsToBounds == true
. Coloque una máscara que coincida con el tamaño de la imagen con el
contentMode
apropiado y la actualizaremos si es necesario. ¡Ahora todo funciona!
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. Los elementos de una pequeña colección están centrados

Aquí todo es muy simple. Este efecto se puede lograr colocando grandes
inset
tanto a la izquierda como a la derecha. Al desplazarse hacia la derecha / izquierda, es necesario comenzar a
bouncing
solo cuando la última celda ha dejado el contenido visible. Es decir, el contenido visible debe ser igual al tamaño de la celda.
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() } }

Más sobre el centrado: cuando la colección termina de desplazarse, el diseño solicita un
contentOffset
para detenerse. Para hacer esto, anule
targetContentOffset(forProposedContentOffset:withScrollingVelocity:)
. Manzana:
Si desea que el comportamiento de desplazamiento se ajuste a límites específicos, puede anular este método y usarlo para cambiar el punto en el que detenerse. Por ejemplo, puede usar este método para siempre dejar de desplazarse en un límite entre elementos, en lugar de detenerse en el medio de un elemento.
Para hacer todo hermoso,
siempre nos detendremos en el centro de la celda más cercana. Calcular el centro de la celda más cercana es una tarea bastante trivial, pero debe tener cuidado y considerar el
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. El tamaño dinámico de los elementos de una colección pequeña.
Si desplaza una colección grande,
contentOffset
cambia por una pequeña. Además, la celda central de una pequeña colección no es tan grande como el resto. Las celdas laterales tienen un tamaño fijo, y la central coincide con la relación de aspecto de la imagen que contiene.

Puede usar la misma técnica que en el caso de paralaje.
UICollectionViewFlowLayout
un
UICollectionViewFlowLayout
personalizado para una colección pequeña y redefinamos
prepareAttributes(attributes:
Dado que la lógica de diseño de la colección pequeña será complicada, crearemos una entidad separada para almacenar y calcular la geometría de la celda.
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
tiene una propiedad
collectionViewContentSize
que determina el tamaño del área que se puede desplazar. Para no complicar nuestra vida, dejémosla constante, independientemente del tamaño de la célula central. Para obtener la geometría correcta para cada celda, debe conocer la
aspectRatio
imagen y la distancia del centro de la celda desde
contentOffset
. Cuanto más cerca
size.width / size.height
la celda, más cerca
size.width / size.height
su
size.width / size.height
aspectRatio
size.width / size.height
aspectRatio
. Al cambiar el tamaño de una celda específica, mueva las celdas restantes (a la derecha y a la izquierda) usando
affineTransform
. Resulta que para calcular la geometría de una celda en particular, debe conocer los atributos de los vecinos (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
se considera casi lo mismo 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 lógica de colocar los elementos de una celda pequeña depende no solo del contentOffset, sino también de las interacciones del usuario
Cuando un usuario se desplaza por una pequeña colección, todas las celdas tienen el mismo tamaño. Al desplazarse por una gran colección, esto no es así. (
ver gifs 3 y 5 ). Vamos a escribir un animador que actualice las propiedades del diseño
ThumbnailLayout
. El animador almacenará
DisplayLink
en sí mismo y llamará al bloque 60 veces por segundo, proporcionando acceso al progreso actual. Es fácil
easing functions
varias
easing functions
al animador. La implementación se puede ver en el github en el enlace al final de la publicación.
Ingresemos la propiedad
ThumbnailLayout
en
ThumbnailLayout
, por la cual se multiplicará la
expanding
todas las
Cell
. Resulta que
aspectRatio
dice cuánto
aspectRatio
imagen en particular afectará a su tamaño si se centra. Con
expandingRate == 0
todas las celdas serán del mismo tamaño. Al comienzo del desplazamiento de una pequeña colección, ejecutaremos un animador que establece la
expandingRate
de
expandingRate
en 0, y al final del desplazamiento, viceversa, en 1. De hecho, al actualizar el diseño, el tamaño de la celda central y las celdas laterales cambiarán. No te
contentOffset
con el contenido ¡
contentOffset
y sacudidas!
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. Animaciones personalizadas para mover y eliminar
Hay muchos artículos que dicen cómo hacer animaciones personalizadas para actualizar celdas, pero en nuestro caso no nos ayudarán. Los artículos y tutoriales describen cómo anular los atributos de una celda actualizada. En nuestro caso, cambiar el diseño de la celda eliminada causa efectos secundarios: la
expanding
celda vecina, que tiende a tomar el lugar de la eliminada durante la animación, cambia.
La actualización de contenido en
UICollectionViewFlowLayout
funciona de la siguiente manera. Después de eliminar / agregar una celda, se
prepare(forCollectionViewUpdates:)
método
prepare(forCollectionViewUpdates:)
, que proporciona una matriz de
UICollectionViewUpdateItem
, que nos dice en qué celdas se actualizaron / eliminaron / agregaron los índices. Luego, el diseño llamará a un grupo de métodos
finalLayoutAttributesForDisappearingItem(at:) initialLayoutAttributesForAppearingDecorationElement(ofKind:at:)
y sus amigos para la decoración / vistas complementarias. Cuando se reciben los atributos para los datos actualizados, se llama a
finalizeCollectionViewUpdates
. Manzana:
La vista de colección llama a este método como el último paso antes de proceder a animar cualquier cambio en su lugar. Este método se llama dentro del bloque de animación utilizado para realizar todas las animaciones de inserción, eliminación y movimiento para que pueda crear animaciones adicionales utilizando este método según sea necesario. De lo contrario, puede usarlo para realizar cualquier tarea de último minuto asociada con la administración de la información de estado de su objeto de diseño.
El problema es que podemos especializar los atributos solo para la celda
actualizada , y necesitamos cambiarlos para todas las celdas, y de diferentes maneras. La nueva celda central debería cambiar el
aspectRatio
, y las laterales deberían
transform
.

Después de examinar cómo funciona la animación predeterminada de las celdas de colección durante la eliminación / inserción, se supo que las celdas de capa en
finalizeCollectionViewUpdates
contienen
CABasicAnimation
, que se puede cambiar allí si desea personalizar la animación para las celdas restantes. Las cosas empeoraron cuando los registros mostraron que entre
performBatchUpdates
y
prepare(forCollectionViewUpdates:)
prepareAttributes(attributes:)
prepare(forCollectionViewUpdates:)
se llama, y puede que ya haya un número incorrecto de celdas, aunque
collectionViewUpdates
aún no se ha iniciado, es muy difícil mantenerlo y entenderlo. ¿Qué se puede hacer al respecto? ¡Puedes deshabilitar estas animaciones incorporadas!
final override func prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem]) { super.prepare(forCollectionViewUpdates: updateItems) CATransaction.begin() CATransaction.setDisableActions(true) } final override func finalizeCollectionViewUpdates() { CATransaction.commit() }
Armados con los animadores ya escritos, haremos todas las animaciones necesarias a petición de eliminación, y
dataSource
actualización de
dataSource
al final de la animación. Por lo tanto, simplificaremos la animación de la colección al actualizar, ya que nosotros mismos controlamos cuándo cambiará el número de celdas.
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?() } }
¿Cómo funcionarán esas animaciones? En
ThumbnailLayout
almacenemos folletos opcionales que actualizan la geometría de celdas específicas.
class ThumbnailLayout { typealias CellUpdate = (Cell) -> Cell var updates: [IndexPath: CellUpdate] = [:]
Con dicha herramienta, puede hacer cualquier cosa con la geometría de las celdas, lanzando actualizaciones durante el trabajo del animador y eliminándolas en el cumplido. También existe la posibilidad de combinar actualizaciones.
updates[index] = newUpdate(updates[index])
El código de animación de eliminación es bastante engorroso; se encuentra en el archivo
DeleteAnimation.swift en el repositorio. La animación del cambio de foco entre celdas se implementa de la misma manera.

6. El índice de la celda "activa" no se pierde al cambiar de orientación
scrollViewDidScroll(_ scrollView:)
se llama incluso si simplemente
contentOffset
algún valor en
contentOffset
, así como cuando cambia la orientación. Cuando se sincroniza el desplazamiento de dos colecciones, pueden surgir algunos problemas durante las actualizaciones del diseño. El siguiente truco ayuda: en las actualizaciones de diseño, puede establecer
scrollView.delegate
en
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 } }
Al actualizar los tamaños de celda en el momento del cambio de orientación, se verá así:
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() } } }
Para no perder el
contentOffset
deseado al cambiar la orientación, puede actualizar
targetIndexPath
en
scrollView.delegate
. Cuando cambie la orientación, el diseño se deshabilitará si anula
shouldInvalidateLayout(forBoundsChange:)
. Al cambiar los
bounds
diseño le pedirá que aclare
contentOffset
, para aclararlo, debe redefinir
targetContentOffset(forProposedContentOffset:)
. Manzana:
Durante las actualizaciones de diseño, o al hacer la transición entre diseños, la vista de colección llama a este método para darle la oportunidad de cambiar el desplazamiento de contenido propuesto para usar al final de la animación. Puede anular este método si las animaciones o la transición pueden hacer que los elementos se posicionen de una manera que no sea óptima para su diseño.
La vista de colección llama a este método después de llamar a los métodos prepare()
y 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