Comprensión de UICollectionViewLayout con la aplicación Fotos

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.

  1. Efecto de paralaje en una gran colección.
  2. Los elementos de una pequeña colección están centrados.
  3. Tamaño dinámico de artículos en una pequeña colección.
  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
  5. Animaciones personalizadas para mover y eliminar
  6. 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 = 44override 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 } } 


imagen

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


imagen


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.leftif 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: IndexPathlet dims: Dimensions let state: Statefunc 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: CGFloatstatic 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: Typefunc 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) // call ScrollAnimation.run(type: .begin) } }​ func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if scrollView == thumbnails.collectionView && !decelerate { thumbnailEndScrolling() } }​ func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { if scrollView == thumbnails.collectionView { thumbnailEndScrolling() } }​ func thumbnailEndScrolling() { handle(event: .endScrolling) // call ScrollAnimation.run(type: .end) } 


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] = [:] // ... override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {​ let cells = (0 ..< itemsCount) .map { IndexPath(row: $0, section: 0) } .map { cell(for: $0, offsetX: offsetWithoutInsets.x) } .map { cell -> Cell in if let update = self.config.updates[cell.indexPath] { return update(cell) } return cell } return cells.compactMap { $0.attributes(from: self, with: cells) } } 

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

Source: https://habr.com/ru/post/477734/


All Articles