Para fazer pizza a partir da metade, usamos dois UICollectionViewLayout
. Estou falando sobre como escrevemos esse layout para iOS, o que encontramos e recusamos.

Protótipo
Quando tivemos a tarefa de criar uma interface para pizza a partir de metades, ficamos um pouco confusos. Eu quero isso de forma bonita, clara e conveniente, e grande, interativa e muito mais. Eu quero ser legal.
Os designers tentaram abordagens diferentes: uma grade de pizzas, cartões horizontais e verticais, mas se estabeleceram no meio golpe. Não sabíamos como obter esse resultado. Começamos com um experimento e demoramos duas semanas para prototipar. Até o layout bruto foi capaz de agradar a todos. A reação foi gravada em vídeo:
Como o UICollectionView funciona
UICollectionView
é uma subclasse de UIScrollView
e é uma UIView regular, com bounds
alterados de um furto. Movendo-o .origin
, mudamos a área visível e a alteração do .size
afeta a escala.
Quando a tela se UICollectionView
cria (ou reutiliza) as células, e as regras para exibi-las são descritas em UICollectionViewLayout
. Vamos trabalhar com ele.
As possibilidades de UICollectionViewLayout
grandes, você pode especificar qualquer relacionamento entre células. Por exemplo, você pode fazer algo muito semelhante ao que o iCarousel pode fazer :

Primeira abordagem
Alterar a aparência da movimentação da tela facilitou a compreensão do layout do layout.
Estamos acostumados ao fato de que as células se movem pela tela (o retângulo verde é a tela do telefone):

Mas vice-versa, essa tela se move em relação às células. As árvores estão paradas, este trem viaja:

No exemplo, os quadros da célula não são alterados, mas os bounds
própria coleção são alterados. Origin
desses bounds
é o contentOffset
conhecido por nós.
Para criar um layout, você precisa passar por dois estágios:
- calcular os tamanhos de todas as células
- mostrar apenas visível na tela.
Layout simples como no UITableView
O layout não funciona diretamente com células. Em vez disso, eles usam UICollectionViewLayoutAttributes
- este é o conjunto de parâmetros que serão aplicados à célula. Frame
- o principal, é responsável pela posição e tamanho da célula. Outros parâmetros: transparência, deslocamento, posição na profundidade da tela, etc.

Para começar, UICollectionViewLayout
escrever um UICollectionViewLayout
simples, que repete o comportamento de um UITableView
: as células ocupam toda a largura, siga uma após a outra.
4 passos à frente:
- Calcular o
frame
para todas as células no método de prepare
. - Retornar células visíveis no
layoutAttributesForElements(in:)
. - Retorne os parâmetros da célula por seu índice no método
layoutAttributesForItem(at:)
. Por exemplo, esse método é usado ao chamar o método de coleção scrollToItem (at :). - Retorne as dimensões do conteúdo resultante em
collectionViewContentSize
. Assim, o coletor descobrirá onde a borda para a qual você pode rolar.
Na maioria dos dispositivos, o tamanho da pizza será de 300 pontos e as coordenadas e tamanhos de todas as células:

Fiz os cálculos em uma aula separada. Consiste em duas partes: calcula todos os quadros no construtor e, em seguida, fornece apenas acesso aos resultados finais:
class TableLayoutCache {
Em seguida, na classe de layout, você só precisa passar parâmetros do cache.
- O método de
prepare
chama o cálculo de todos os quadros. - layoutAttributesForElements (em :) filtrará os quadros. Se o quadro cruzar com a área visível, a célula precisará ser exibida: calcule todos os atributos e retorne-o à matriz de células visíveis.
- layoutAttributesForItem (at :) - Calcula os atributos para uma única célula.
class TableLayout: UICollectionViewLayout { override var collectionViewContentSize: CGSize { return cache.contentSize } override func prepare() { super.prepare() let numberOfItems = collectionView!.numberOfItems(inSection: section) cache = TableLayoutCache(itemSize: itemSize, collectionWidth: collectionView!.bounds.width) cache.recalculateDefaultFrames(numberOfItems: numberOfItems) } override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { let indexes = cache.visibleRows(in: rect) let cells = indexes.map { (row) -> UICollectionViewLayoutAttributes? in let path = IndexPath(row: row, section: section) let attributes = layoutAttributesForItem(at: path) return attributes }.compactMap { $0 } return cells } override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath) attributes.frame = cache.defaultCellFrame(atRow: indexPath.row) return attributes } var itemSize: CGSize = .zero { didSet { invalidateLayout() } } private let section = 0 var cache = TableLayoutCache.zero }
Mudamos de acordo com suas necessidades
Classificamos a visualização da tabela, mas agora precisamos criar um layout dinâmico. A cada troca de dedos, recalcularemos os atributos das células: pegue os quadros que já foram contados e altere-os usando .transform
. Todas as alterações serão feitas em uma subclasse de PizzaHalfSelectorLayout
.
Lemos o índice atual de pizza
Por conveniência, você pode esquecer o contentOffset
e substituí-lo pelo número da pizza atual. Então você não precisará mais pensar em coordenadas, todas as decisões serão em torno do número da pizza e seu grau de deslocamento do centro da tela.
São necessários dois métodos: um converte contentOffset
em um número de pizza e o outro vice-versa:
extension PizzaHalfSelectorLayout { func contentOffset(for pizzaIndex: Int) -> CGPoint { let cellHeight = itemSize.height let screenHalf = collectionView!.bounds.height / 2 let midY = cellHeight * CGFloat(pizzaIndex) + cellHeight / 2 let newY = midY - screenHalf return CGPoint(x: 0, y: newY) } func pizzaIndex(offset: CGPoint) -> CGFloat { let cellHeight = itemSize.height let proposedCenterY = collectionView!.screenCenterYOffset(for: offset) let pizzaIndex = proposedCenterY / cellHeight return pizzaIndex } }
O cálculo contentOffset
para o centro da tela é renderizado em extension
:
extension UIScrollView { func screenCenterYOffset(for offset: CGPoint? = nil) -> CGFloat { let offsetY = offset?.y ?? contentOffset.y let contentOffsetY = offsetY + bounds.height / 2 return contentOffsetY } }
Paramos na pizza no centro
A primeira coisa que precisamos fazer é parar a pizza no centro da tela. O método targetContentOffset(forProposedContentOffset:)
pergunta onde parar se, na velocidade atual, iria parar no proposedContentOffset
.
O cálculo é simples: veja em qual pizza o proposedContentOffset
se encaixará e role para que fique no centro:
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint { let pizzaIndex = Int(self.pizzaIndex(offset: proposedContentOffset)) let projectedOffset = contentOffset(for: pizzaIndex) return projectedOffset }
UIScrollView
possui duas .normal
rolagem: .normal
e .normal
. .fast
mais adequado para .fast
:
collectionView!.decelerationRate = .fast
Mas há um problema: se rolamos um pouco, precisamos permanecer na pizza e não pular para a próxima. Não existe um método para alterar a velocidade, portanto, o salto reverso, embora a uma pequena distância, mas com uma velocidade muito alta:

Cuidado, corte!
Se a célula não mudar, retornamos o contentOffset
atual, para que a rolagem pare. Em seguida, nós mesmos iremos rolar para o local anterior usando o scrollToItem
padrão. Infelizmente, você também precisa rolar de forma assíncrona, para que o código seja chamado após o return
, e haverá pouco desbotamento durante a animação.
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint { let pizzaIndex = Int(self.pizzaIndex(offset: proposedContentOffset)) let projectedOffset = contentOffset(for: pizzaIndex) let sameCell = pizzaIndex == currentPizzaIndexInt if sameCell { animateBackwardScroll(to: pizzaIndex) return collectionView!.contentOffset
O problema se foi, agora a pizza retorna sem problemas:

Aumentar Central Pizza
Recontamos o layout ao mover
É necessário fazer a pizza central aumentar gradualmente à medida que se aproxima do centro. Para fazer isso, você precisa calcular os parâmetros não uma vez no início, mas sempre no deslocamento. Liga simplesmente:
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { return true }
Agora, com cada deslocamento, os métodos prepare
e layoutAttributesForElements(in:)
serão chamados. Assim, podemos atualizar o UICollectionViewLayoutAttributes
várias vezes seguidas, alterando suavemente a posição e a transparência.
No layout da tabela, as células estavam entre si e suas coordenadas foram contadas uma vez. Agora vamos alterá-los, dependendo da posição relativa ao centro da tela. Adicione um método que os altere rapidamente.
No método layoutAttributesForElements
, layoutAttributesForElements
precisa obter os atributos da superclasse, filtrar os atributos das células e passá-los para o método updateCells
:
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { guard let elements = super.layoutAttributesForElements(in: rect) else { return nil } let cells = elements.filter { $0.representedElementCategory == .cell } self.updateCells(cells) }
Agora vamos mudar os atributos da célula em uma função:
private func updateCells(_ cells: [UICollectionViewLayoutAttributes])
Durante o movimento, precisamos alterar a transparência, o tamanho e manter a pizza no centro.
A posição da célula em relação ao centro da tela é convenientemente apresentada em uma forma normalizada. Se a célula estiver no centro, o parâmetro será 0, se for deslocado, o parâmetro será alterado de -1 ao subir para 1 para 1. Se os valores estiverem mais distantes de zero que 1 / -1, isso significa que a célula não é mais central e parou de mudar. Eu chamei esse parâmetro de escala:

Você precisa calcular a diferença entre o centro do quadro e o centro da tela. Dividindo a diferença por uma constante, normalizamos o valor, e min e max levarão a um intervalo de -1 a +1:
extension PizzaHalfSelectorLayout { func scale(for row: Int) -> CGFloat { let frame = cache.defaultCellFrame(atRow: row) let scale = self.scale(for: frame) return scale.normalized } func scale(for frame: CGRect) -> CGFloat { let criticalOffset = PizzaHalfSelectorLayout.criticalOffsetFromCenter
Tamanho
Tendo uma scale
normalizada, você pode fazer qualquer coisa. As alterações de -1 a +1 são muito fortes, precisam ser convertidas para o tamanho. Por exemplo, queremos que o tamanho diminua para um máximo de 0,6 do tamanho da pizza central:
private func updateCells(_ cells: [UICollectionViewLayoutAttributes]) { for cell in cells { let normScale = scale(for: cell.indexPath.row) let scale = 1 - PizzaHalfSelectorLayout.scaleFactor * abs(normScale) cell.transform = CGAffineTransform(scaleX: scale, y: scale) } }
.transform
redimensiona em relação ao centro das células. A célula central possui normScale = 0, seu tamanho não muda:

Transparência
A transparência pode ser alterada através do parâmetro alpha
. O valor da scale
que usamos na transform
também é adequado.
cell.alpha = scale
Agora a pizza muda de tamanho e transparência. Já não é tão chato como nas tabelas regulares.

Bisectando
Antes disso, trabalhamos com uma pizza: definimos o sistema de referência do centro, alteramos o tamanho e a transparência. Agora você precisa dividir ao meio.
Usar uma coleção para isso é muito difícil: você precisará escrever seu próprio manipulador de gestos para cada metade. É mais fácil fazer duas coleções, cada uma com seu próprio layout. Só agora, em vez de uma pizza inteira, haverá metades.
Dois controladores, um contêiner
Quase sempre, eu quebro uma tela em vários UIViewController
, cada um com sua própria tarefa. Desta vez, ficou assim:

- O controlador principal: todas as peças são montadas nele e o botão "mix".
- Controlador com dois contêineres para metades, uma assinatura central e indicadores de rolagem.
- O controlador com um coletor (branco direito).
- Painel inferior com preço.
Para distinguir entre a metade esquerda e a direita, iniciei o enum
, ele é armazenado no layout na .orientation
.orientation:
enum PizzaHalfOrientation { case left case right func opposite() -> PizzaHalfOrientation { switch self { case .left: return .right case .right: return .left } } }
Mudamos as metades para o centro
O layout anterior parou de fazer o que esperamos: as metades começaram a mudar para o centro de suas coleções, não para o centro da tela:

A correção é simples: você precisa deslocar horizontalmente as células até a metade do centro da tela:
func centerAlignedFrame(for element: UICollectionViewLayoutAttributes, scale: CGFloat) -> CGRect { let hOffset = horizontalOffset(for: element, scale: scale) switch orientation { case .left:
A distância entre as metades é imediatamente controlada.

Deslocamento da célula
Era fácil colocar pizza redonda em um quadrado e, pela metade, você precisa de meio quadrado:

Você pode reescrever o cálculo dos quadros: reduza pela metade a largura, alinhe os quadros ao centro de maneira diferente para cada metade. Para simplificar, basta alterar o contentMode
imagem dentro da célula:
class PizzaHalfCell: UICollectionViewCell { var halfOrientation: PizzaHalfOrientation = .left { didSet { imageView?.contentMode = halfOrientation == .left ? .topRight : .topLeft self.setNeedsLayout() } } }

Pressione a pizza verticalmente
As pizzas diminuíram, mas a distância entre seus centros não mudou, grandes lacunas apareceram. Você pode compensá-los da mesma maneira que alinhamos as metades no centro.
private func verticalOffset(for element: UICollectionViewLayoutAttributes, scale: CGFloat) -> CGFloat { let offsetFromCenter = offsetFromScreenCenter(element.frame) let vOffset: CGFloat = PizzaHalfSelectorLayout.verticalOffset( offsetFromCenter: offsetFromCenter, scale: scale) return vOffset } static func verticalOffset(offsetFromCenter: CGFloat, scale: CGFloat) -> CGFloat { return -offsetFromCenter / 4 * scale }
Como resultado, todas as compensações são assim:
func centerAlignedFrame(for element: UICollectionViewLayoutAttributes, scale: CGFloat) -> CGRect { let hOffset = horizontalOffset(for: element, scale: scale) let vOffset = verticalOffset (for: element, scale: scale) switch orientation { case .left:
E a configuração da célula é assim:
private func updateCells(_ cells: [UICollectionViewLayoutAttributes]) { for cell in cells { let normScale = scale(for: cell.indexPath.row) let scale = 1 - PizzaHalfSelectorLayout.scaleFactor * abs(normScale) cell.alpha = scale cell.frame = centerAlignedFrame(for: cell, scale: scale) cell.transform = CGAffineTransform(scaleX: scale, y: scale) cell.zIndex = cellZLevel } }
Não confunda: a configuração do quadro deve estar antes da transformação. Se você alterar a ordem, o resultado dos cálculos será completamente diferente.
Feito! Cortamos as metades e as alinhamos ao centro:

Adicionar legendas
Os cabeçalhos são criados da mesma maneira que as células, somente em vez de UICollectionViewLayoutAttributes(forCellWith:)
você precisa usar o construtor UICollectionViewLayoutAttributes(forSupplementaryViewOfKind:)
e retorne-os junto com os parâmetros da célula em layoutAttributesForElements(in:)
Primeiro, descrevemos um método para obter um cabeçalho pelo IndexPath
:
override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { let attributes = UICollectionViewLayoutAttributes( forSupplementaryViewOfKind: elementKind, with: indexPath) attributes.frame = defaultFrameForHeader(at: indexPath) attributes.zIndex = headerZLevel return attributes }
O cálculo do quadro está oculto no método defaultFrameForHeader
(a defaultFrameForHeader
).
Agora você pode obter o IndexPath
células visíveis e mostrar os IndexPath
para elas:
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { … let visiblePaths = cells.map { $0.indexPath } let headers = self.headers(for: visiblePaths) updateHeaders(headers) return cells + headers }
Uma chamada de função terrivelmente longa está oculta nos headers(for:)
método:
func headers(for paths: [IndexPath]) -> [UICollectionViewLayoutAttributes] { let headers: [UICollectionViewLayoutAttributes] = paths.map { layoutAttributesForSupplementaryView( ofKind: UICollectionView.elementKindSectionHeader, at: $0) }.compactMap { $0 } return headers }
zIndex
Agora, as células e assinaturas estão no mesmo nível de "altura", para que possam ser colocadas em camadas umas sobre as outras. Para manter os cabeçalhos sempre mais altos, dê-lhes zIndex
maior que zero. Por exemplo, 100.
Fixamos uma posição (na verdade não)
As assinaturas fixadas na tela são um pouco intrigantes. Você deseja corrigir, mas vice-versa, mova-se constantemente com os bounds
:

Tudo é simples no código: obtemos a posição da assinatura na tela e a mudamos para contentOffset
:
func defaultFrameForHeader(at indexPath: IndexPath) -> CGRect { let inset = max(collectionView!.layoutMargins.left, collectionView!.layoutMargins.right) let y = collectionView!.bounds.minY let height = collectionView!.bounds.height let width = collectionView!.bounds.width let headerWidth = width - inset * 2 let headerHeight: CGFloat = 60 let vOffset: CGFloat = 30 let screenY = (height - itemSize.height) / 2 - headerHeight / 2 - vOffset return CGRect(x: inset, y: y + screenY, width: headerWidth, height: headerHeight) }
A altura das assinaturas pode ser diferente; é melhor considerá-lo no delegado (e no cache lá).
Animando assinaturas
Tudo é muito parecido com as células. Com base na scale
atual, você pode calcular a transparência da célula. O deslocamento pode ser definido via .transform
, para que a inscrição seja alterada em relação ao seu quadro:
func updateHeaders(_ headers: [UICollectionViewLayoutAttributes]) { for header in headers { let scale = self.scale(for: header.indexPath.row) let alpha = 1 - abs(scale) header.alpha = alpha let translation = 20 * scale header.transform = CGAffineTransform(translationX: 0, y: translation) } }

Otimizar
Depois de adicionar cabeçalhos, o desempenho diminuiu drasticamente. Isso aconteceu porque UICollectionViewLayoutAttributes
as assinaturas, mas ainda as devolvemos para UICollectionViewLayoutAttributes
. A partir disso, os cabeçalhos são adicionados à hierarquia, participam do layout, mas não são exibidos. Mostramos apenas as células que se cruzam com os bounds
atuais e os cabeçalhos precisam ser filtrados por alpha
:
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { … let visibleHeaders = headers.filter { $0.alpha > 0 } return cells + visibleHeaders }
Concordamos com a assinatura central (receita original)
Fizemos um ótimo trabalho, mas houve uma contradição na interface - se você escolher duas metades idênticas, elas se transformarão em uma pizza comum.
Decidimos deixá-lo assim, mas processamos corretamente a condição, mostrando que era uma pizza comum. Nossa nova tarefa é mostrar um rótulo no centro para pizzas idênticas e ocultar ao longo das bordas.

Somente o layout é muito difícil de resolver, porque a inscrição está na junção de dois coletores. Será mais fácil se o controlador, que contém as duas coleções, coordenar o movimento de todas as assinaturas.
Ao rolar, passamos o índice atual para o controlador, ele envia o índice para a metade oposta. Se os índices corresponderem, ele exibirá o título da pizza original e, se diferente, as assinaturas de cada metade serão visíveis.
Como inventar seus layouts
A parte mais difícil foi descobrir como colocar sua ideia na computação. Por exemplo, quero que as pizzas rolem como um tambor. Para entender o problema, realizei 4 etapas comuns:
- Eu desenhei alguns estados.
- Eu entendi como os elementos estão relacionados à posição da tela (os elementos se movem em relação ao centro da tela).
- Variáveis criadas que são convenientes para trabalhar (centro da tela, moldura central da pizza, balança).
- Eu vim com etapas simples, cada uma das quais pode ser verificada.
Estados e animações são fáceis de desenhar no Keynote. Peguei o layout padrão e desenhei os dois primeiros passos:

O vídeo é assim:

Foram necessárias três alterações:
- Em vez de quadros do cache, usaremos
centerPizzaFrame
. - Usando
scale
leia o deslocamento desse quadro. Recalcule o zIndex
.
func centerAlignedFrame(for element: UICollectionViewLayoutAttributes, scale: CGFloat) -> CGRect { let hOffset = self.horizontalOffset(for: element, scale: scale) let vOffset = self.verticalOffset (for: element, scale: scale) switch self.pizzaHalf { case .left:
Depois que as células se sobrepõem, é necessário configurá-las na ordem correta com zIndex
. A idéia é a seguinte: quanto mais próxima a célula da pizza central, mais próxima a tela e maior zIndex
.
private func updateCells(_ cells: [UICollectionViewLayoutAttributes]) { for cell in cells { let normScale = self.scale(for: cell.indexPath.row) let scale = 1 - PizzaHalfSelectorLayout.scaleFactor * abs(normScale) cell.alpha = 1
Então, se a terceira célula estiver atual, ocorrerá assim:
row: zIndex` 0: 0 1: 1 2: 2 3: 10 — 4: 5 5: 4 6: 3 7: 2 8: 1 9: 0
Eu não obtive esse layout na produção, pois precisaria de fotos com fundo transparente.
Data de lançamento
Ao criar essa interface, nos envolvemos em um pequeno experimento: pela primeira vez, escrevemos um layout tão incomum e depois adicionamos uma transição interativa à tela do cartão. O experimento valeu a pena: as pizzas da metade entraram nas principais vendas e ficaram em segundo lugar, perdendo apenas para os pepperoni clássicos.
Obviamente, a produção precisava de mais trabalho:
- corte imagens da pizza ao meio,
- , : , ,
- ,
- ,
- -,
- «»,
- Voice Over.
:
github , .
UICollectionViewLayout
, A Tour of UICollectionView
, .