Olá Habr! Meu nome é Nikita, trabalho com SDKs móveis da ABBYY e também lida com o componente de interface do usuário para digitalizar e visualizar convenientemente documentos de várias páginas em um smartphone. Esse componente reduz o tempo para o desenvolvimento de aplicativos com base na
tecnologia ABBYY Mobile Capture e consiste em várias partes. Em primeiro lugar, uma câmera para digitalizar documentos; segundo, uma tela do editor com os resultados da captura (ou seja, fotos tiradas automaticamente) e uma tela para corrigir as margens do documento.
É suficiente para o desenvolvedor chamar alguns métodos - e agora em seu aplicativo já está disponível uma câmera que digitaliza documentos automaticamente. Mas, além das câmeras configuradas, você precisa fornecer aos clientes acesso conveniente aos resultados da digitalização, ou seja, fotos tiradas automaticamente. E se o cliente digitalizar o contrato ou a carta patente, poderá haver muitas dessas fotos.
Neste post, falarei sobre as dificuldades que surgiram durante a implementação da tela do editor com os resultados da captura de documentos. A tela em si é um
UICollectionView
dois, eu os chamarei de grandes e pequenos. Omitirei as possibilidades de ajustar manualmente as bordas do documento e outros trabalhos com o documento, e focarei nas animações e nos recursos de layout durante a rolagem. Abaixo no GIF, você pode ver o que aconteceu no final. Um link para o repositório estará no final do artigo.
Como referência, muitas vezes presto atenção aos aplicativos de sistema da Apple. Quando você analisa cuidadosamente as animações e outras soluções de interface de suas aplicações, começa a admirar a atitude atenta deles em relação a várias insignificâncias. Agora, veremos o aplicativo
Fotos (iOS 12) como uma referência. Chamarei sua atenção para os recursos específicos deste aplicativo e, em seguida, tentaremos implementá-los.
UICollectionViewFlowLayout
maioria das
UICollectionViewFlowLayout
personalização
UICollectionViewFlowLayout
, veremos como técnicas comuns como paralaxe e carrossel são implementadas e discutiremos os problemas associados às animações personalizadas ao inserir e excluir células.
Revisão de Recursos
Para adicionar detalhes, descreverei quais coisinhas específicas me agradaram no aplicativo
Fotos e depois as implementarei na ordem apropriada.
- Efeito paralaxe em uma grande coleção
- Os elementos de uma pequena coleção são centralizados.
- Tamanho dinâmico de itens em uma pequena coleção
- A lógica de colocar os elementos de uma célula pequena depende não apenas do contentOffset, mas também das interações do usuário
- Animações personalizadas para mover e excluir
- O índice da célula "ativa" não é perdido ao alterar a orientação
1. Parallax
O que é paralaxe?
A rolagem de paralaxe é uma técnica em computação gráfica, na qual as imagens de fundo passam pela câmera mais lentamente do que as imagens de primeiro plano, criando uma ilusão de profundidade em uma cena 2D e aumentando a sensação de imersão na experiência virtual.
Você pode notar que, ao rolar, o quadro da célula se move mais rápido que a imagem que está nele.
Vamos começar! Crie uma subclasse da célula, coloque o UIImageView nela.
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() } } }
Agora você precisa entender como mudar o
imageView
, criando um efeito de paralaxe. Para fazer isso, você precisa redefinir o comportamento das células durante a rolagem. Apple:
Evite sub-classificar UICollectionView
. A exibição da coleção tem pouca ou nenhuma aparência própria. Em vez disso, ele obtém todas as visualizações do objeto de fonte de dados e todas as informações relacionadas ao layout do objeto de layout. Se você estiver tentando organizar os itens em três dimensões, a maneira correta de fazer isso é implementar um layout personalizado que defina a transformação 3D de cada célula e visualize-a adequadamente.
Ok, vamos criar nosso
objeto de layout .
UICollectionView
possui uma propriedade
collectionViewLayout
, na qual ele aprende informações sobre o posicionamento das células.
UICollectionViewFlowLayout
é uma implementação do
UICollectionViewLayout
abstrato, que é a propriedade
collectionViewLayout
.
UICollectionViewLayout
está aguardando alguém para subclassificá-lo e fornecer o conteúdo apropriado. UICollectionViewFlowLayout
é uma classe concreta de UICollectionViewLayout
que possui todos os seus quatro membros implementados, da maneira que as células serão organizadas em uma grade.
Crie uma subclasse de
UICollectionViewFlowLayout
e substitua seu
layoutAttributesForElements(in:)
. O método retorna uma matriz de
UICollectionViewLayoutAttributes
, que fornece informações sobre como exibir uma célula específica.
A coleção solicita atributos sempre que o
contentOffset
muda, bem como quando o layout é inválido. Além disso, criaremos atributos personalizados adicionando a propriedade
parallaxValue
, que determina quanto o quadro da imagem está atrasado em relação ao quadro da célula. Para subclasses de atributo, você deve substituir
NSCopiyng
por elas. Apple:
Se você subclassificar e implementar qualquer atributo de layout customizado, também deverá substituir o método isEqual: herdado para comparar os valores de suas propriedades. No iOS 7 e posterior, a exibição de coleção não aplica atributos de layout se esses atributos não foram alterados. Determina se os atributos foram alterados comparando os objetos de atributo antigo e novo usando o método isEqual:. Como a implementação padrão desse método verifica apenas as propriedades existentes dessa classe, você deve implementar sua própria versão do método para comparar propriedades adicionais. Se suas propriedades personalizadas forem todas iguais, chame super
e retorne o valor resultante no final de sua implementação.
Como descobrir o
parallaxValue
? Vamos calcular quanto você precisa mover o centro da célula para que ela fique no centro. Se essa distância for maior que a largura da célula, martele-a. Caso contrário, divida essa distância pela largura da
célula . Quanto mais próxima essa distância é de zero, mais fraco é o efeito de paralaxe.
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 } }



Quando a coleção recebe os atributos necessários, as células os
aplicam . Esse comportamento pode ser substituído na subclasse da célula. Vamos
imageView
no valor, dependendo do
parallaxValue
. No entanto, para que a troca de imagens com
contentMode == .aspectFit
funcione corretamente, isso não é suficiente, porque o quadro da imagem não coincide com o quadro
imageView
, pelo qual o conteúdo é cortado quando
clipsToBounds == true
. Coloque uma máscara que corresponda ao tamanho da imagem com o
contentMode
apropriado e iremos atualizá-la, se necessário. Agora tudo 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. Os elementos de uma pequena coleção são centralizados

Tudo é muito simples aqui. Este efeito pode ser alcançado definindo
inset
grandes à esquerda e à direita. Ao rolar para a direita / esquerda, é necessário começar a
bouncing
somente quando a última célula tiver deixado o conteúdo visível. Ou seja, o conteúdo visível deve ser igual ao tamanho da célula.
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() } }

Mais sobre centralização: quando a coleção termina a rolagem, o layout solicita um
contentOffset
para parar. Para fazer isso, substitua
targetContentOffset(forProposedContentOffset:withScrollingVelocity:)
. Apple:
Se você deseja que o comportamento da rolagem se ajuste a limites específicos, você pode substituir esse método e usá-lo para alterar o ponto no qual parar. Por exemplo, você pode usar esse método para sempre parar de rolar em um limite entre itens, em vez de parar no meio de um item.
Para deixar tudo bonito,
sempre paramos no centro da célula mais próxima. Calcular o centro da célula mais próxima é uma tarefa bastante trivial, mas você precisa ter cuidado e considerar o
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. O tamanho dinâmico dos elementos de uma pequena coleção
Se você rolar uma coleção grande, o
contentOffset
alterado para uma coleção pequena. Além disso, a célula central de uma pequena coleção não é tão grande quanto as demais. As células laterais têm um tamanho fixo e a central coincide com a proporção da imagem que ela contém.

Você pode usar a mesma técnica que no caso da paralaxe. Vamos criar um
UICollectionViewFlowLayout
personalizado para uma pequena coleção e redefinir
prepareAttributes(attributes:
Como a lógica de layout da pequena coleção será complicada, criaremos uma entidade separada para armazenar e calcular a geometria da célula.
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
possui uma propriedade
collectionViewContentSize
que determina o tamanho da área que pode ser rolada. Para não complicar nossa vida, deixe-a constante, independente do tamanho da célula central. Para a geometria correta de cada célula, é necessário conhecer o
aspectRatio
imagem e o afastamento do centro da célula a partir do
contentOffset
. Quanto mais próxima a célula, mais próximo seu
size.width / size.height
aspectRatio
.
aspectRatio
size.width / size.height
aspectRatio
. Ao redimensionar uma célula específica, mova as células restantes (para a direita e esquerda) usando
affineTransform
. Acontece que, para calcular a geometria de uma célula específica, você precisa conhecer os atributos dos vizinhos (visíveis).
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
é considerado quase o mesmo 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. A lógica de colocar os elementos de uma célula pequena depende não apenas do contentOffset, mas também das interações do usuário
Quando um usuário percorre uma pequena coleção, todas as células têm o mesmo tamanho. Ao rolar uma coleção grande, não é assim. (
veja os gifs 3 e 5 ). Vamos escrever um animador que atualize as propriedades do layout
ThumbnailLayout
. O animador armazenará o
DisplayLink
em si mesmo e chamará o bloco 60 vezes por segundo, fornecendo acesso ao progresso atual. É fácil
easing functions
várias
easing functions
no animador. A implementação pode ser visualizada no github no link no final da postagem.
Vamos inserir a propriedade
expandingRate
em
ThumbnailLayout
, pela qual a
expanding
todas as
Cell
será multiplicada. Acontece que o
expandingRate
diz quanto o
aspectRatio
imagem em particular afeta seu tamanho se ficar centrado. Com
expandingRate == 0
todas as células terão o mesmo tamanho. No início do pergaminho de uma pequena coleção, executaremos um animador que define o
expandingRate
como 0 e, no final do pergaminho, vice-versa, como 1. De fato, ao atualizar o layout, o tamanho da célula central e das células laterais mudará. Não
contentOffset
com
contentOffset
e empurrões!
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. Animações personalizadas para mover e excluir
Existem muitos artigos dizendo como criar animações personalizadas para atualizar células, mas, no nosso caso, elas não nos ajudarão. Os artigos e tutoriais descrevem como substituir os atributos de uma célula atualizada. No nosso caso, alterar o layout da célula excluída causa efeitos colaterais - a
expanding
célula vizinha, que tende a substituir o excluído durante a animação, muda.
A atualização de conteúdo em um
UICollectionViewFlowLayout
funciona da seguinte maneira. Após excluir / adicionar uma célula, o método
prepare(forCollectionViewUpdates:)
é iniciado, fornecendo uma matriz de
UICollectionViewUpdateItem
, que informa quais células em quais índices foram atualizados / excluídos / adicionados. Em seguida, o layout chamará um grupo de métodos
finalLayoutAttributesForDisappearingItem(at:) initialLayoutAttributesForAppearingDecorationElement(ofKind:at:)
e seus amigos pela decoração / vistas complementares. Quando os atributos para os dados atualizados são recebidos,
finalizeCollectionViewUpdates
é chamado. Apple:
A visualização de coleção chama esse método como a última etapa antes de continuar a animar as alterações no local. Esse método é chamado dentro do bloco de animação usado para executar todas as inserções, exclusões e movimentação de animações, para que você possa criar animações adicionais usando esse método conforme necessário. Caso contrário, você poderá usá-lo para executar tarefas de última hora associadas ao gerenciamento das informações de estado do objeto de layout.
O problema é que podemos especializar atributos apenas para a célula
atualizada e precisamos alterá-los para todas as células e de maneiras diferentes. A nova célula central deve mudar
aspectRatio
e as laterais devem se
transform
.

Após examinar como funciona a animação padrão das células de coleção durante a exclusão / inserção, ficou
CABasicAnimation
que as células da camada em
finalizeCollectionViewUpdates
contêm
CABasicAnimation
, que pode ser alterado lá se você desejar personalizar a animação para as células restantes. As coisas pioraram quando os logs mostraram que, entre
performBatchUpdates
e
prepare(forCollectionViewUpdates:)
O que pode ser feito sobre isso? Você pode desativar essas animações internas!
final override func prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem]) { super.prepare(forCollectionViewUpdates: updateItems) CATransaction.begin() CATransaction.setDisableActions(true) } final override func finalizeCollectionViewUpdates() { CATransaction.commit() }
Armado com os animadores já escritos, faremos todas as animações necessárias mediante solicitação para exclusão e
dataSource
atualização do
dataSource
no final da animação. Assim, simplificaremos a animação da coleção durante a atualização, pois controlamos a nós mesmos quando o número de células será alterado.
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?() } }
Como essas animações funcionam? No
ThumbnailLayout
vamos armazenar folhetos opcionais que atualizam a geometria de células específicas.
class ThumbnailLayout { typealias CellUpdate = (Cell) -> Cell var updates: [IndexPath: CellUpdate] = [:]
Com essa ferramenta, você pode fazer qualquer coisa com a geometria das células, lançando atualizações durante o trabalho do animador e removendo-as no elogio. Há também a possibilidade de combinar atualizações.
updates[index] = newUpdate(updates[index])
O código de animação para exclusão é bastante complicado, está localizado no arquivo
DeleteAnimation.swift no repositório. A animação da troca de foco entre células é implementada da mesma maneira.

6. O índice da célula "ativa" não é perdido ao alterar a orientação
scrollViewDidScroll(_ scrollView:)
é chamado mesmo se você simplesmente
contentOffset
algum valor no
contentOffset
, bem como ao alterar a orientação. Quando a rolagem de duas coleções é sincronizada, alguns problemas podem surgir durante as atualizações do layout. O seguinte truque ajuda: nas atualizações de layout, você pode definir
scrollView.delegate
como
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 } }
Ao atualizar os tamanhos das células no momento da alteração da orientação, será semelhante a:
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 não perder o
contentOffset
desejado ao alterar a orientação, você pode atualizar o
targetIndexPath
em
scrollView.delegate
. Quando você altera a orientação, o layout será desativado se você substituir
shouldInvalidateLayout(forBoundsChange:)
. Ao alterar os
bounds
layout solicitará o esclarecimento de
contentOffset
. Para esclarecê-lo, é necessário redefinir
targetContentOffset(forProposedContentOffset:)
. Apple:
Durante as atualizações de layout ou durante a transição entre layouts, a exibição de coleção chama esse método para oferecer a oportunidade de alterar o deslocamento de conteúdo proposto a ser usado no final da animação. Você pode substituir esse método se as animações ou a transição puderem fazer com que os itens sejam posicionados de uma maneira que não seja ideal para o seu design.
A exibição de coleção chama esse método depois de chamar os métodos prepare()
e 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