Para hacer pizza a partir de mitades, utilizamos dos UICollectionViewLayout
. Estoy hablando de cómo escribimos ese diseño para iOS, lo que encontramos y lo que rechazamos.

Prototipo
Cuando tuvimos la tarea de hacer una interfaz para pizza a partir de mitades, estábamos un poco confundidos. Lo quiero maravillosamente, y claramente, y convenientemente, y grande, e interactivamente y mucho más. Quiero hacerlo bien
Los diseñadores probaron diferentes enfoques: una cuadrícula de pizzas, tarjetas horizontales y verticales, pero se asentaron en el medio golpe. No sabíamos cómo lograr ese resultado, por lo que comenzamos con un experimento y el prototipo tardó dos semanas. Incluso el diseño burdo fue capaz de complacer a todos. La reacción se grabó en video:
Cómo funciona UICollectionView
UICollectionView
es una subclase de UIScrollView
, y es una UIView normal, con bounds
cambian de un deslizamiento. Moviéndolo .origin
, cambiamos el área visible, y cambiar .size
afecta la escala.
Cuando la pantalla se UICollectionView
crea (o reutiliza) las celdas, y las reglas para mostrarlas se describen en UICollectionViewLayout
. Trabajaremos con él.
Las posibilidades de UICollectionViewLayout
grandes, puede especificar cualquier relación entre celdas. Por ejemplo, puede hacer algo muy similar a lo que puede hacer iCarousel :

Primer acercamiento
Cambiar el aspecto al mover la pantalla me facilitó la comprensión del diseño del diseño.
Estamos acostumbrados al hecho de que las celdas se mueven alrededor de la pantalla (el rectángulo verde es la pantalla del teléfono):

Pero viceversa, esta pantalla se mueve en relación con las celdas. Los árboles están quietos, este tren viaja:

En el ejemplo, los marcos de las celdas no cambian, pero los bounds
la colección sí cambian. Origin
estos bounds
es el contentOffset
que conocemos.
Para crear un diseño, debe pasar por dos etapas:
- calcular los tamaños de todas las celdas
- mostrar solo visible en la pantalla.
Diseño simple como en UITableView
El diseño no funciona con celdas directamente. En cambio, usan UICollectionViewLayoutAttributes
: este es el conjunto de parámetros que se aplicarán a la celda. Frame
: el principal, es responsable de la posición y el tamaño de la celda. Otros parámetros: transparencia, desplazamiento, posición en la profundidad de la pantalla, etc.

Para comenzar, escribamos un UICollectionViewLayout
simple, que repite el comportamiento de un UITableView
: las celdas ocupan todo el ancho, van una tras otra.
4 pasos por delante:
- Calcule el
frame
para todas las celdas en el método de prepare
. - Devuelve celdas visibles en
layoutAttributesForElements(in:)
método. - Devuelva los parámetros de celda por su índice en el
layoutAttributesForItem(at:)
. Por ejemplo, este método se usa cuando se llama al método de colección scrollToItem (en :). - Devuelve las dimensiones del contenido resultante en
collectionViewContentSize
. Entonces, el recolector descubrirá a dónde se puede desplazar el borde.
En la mayoría de los dispositivos, el tamaño de la pizza será de 300 puntos, luego las coordenadas y tamaños de todas las celdas:

Hice los cálculos en una clase separada. Consta de dos partes: calcula todos los marcos en el constructor y luego solo da acceso a los resultados finales:
class TableLayoutCache {
Luego, en la clase de diseño, solo necesita pasar los parámetros del caché.
- El método de
prepare
invoca el cálculo de todos los marcos. - layoutAttributesForElements (en :) filtrará los marcos. Si el marco se cruza con el área visible, entonces la celda debe mostrarse: calcule todos los atributos y devuélvala a la matriz de celdas visibles.
- layoutAttributesForItem (at :) - Calcula los atributos para una sola celda.
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 }
Cambiamos de acuerdo a sus necesidades.
Hemos ordenado la vista de tabla, pero ahora necesitamos hacer un diseño dinámico. En cada cambio de dedo, volveremos a calcular los atributos de las celdas: tome fotogramas que ya se han contado y cámbielos usando .transform
. Todos los cambios se realizarán en una subclase de PizzaHalfSelectorLayout
.
Leemos el índice actual de pizza
Para mayor comodidad, puede olvidarse de contentOffset
y reemplazarlo con el número de la pizza actual. Entonces ya no necesitará pensar en las coordenadas, todas las decisiones serán en torno al número de pizza y su grado de desplazamiento desde el centro de la pantalla.
Se necesitan dos métodos: uno convierte contentOffset
en un número de pizza, el otro viceversa:
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 } }
El cálculo de contentOffset
para el centro de la pantalla se representa en extension
:
extension UIScrollView { func screenCenterYOffset(for offset: CGPoint? = nil) -> CGFloat { let offsetY = offset?.y ?? contentOffset.y let contentOffsetY = offsetY + bounds.height / 2 return contentOffsetY } }
Nos detenemos en la pizza del centro.
Lo primero que debemos hacer es detener la pizza en el centro de la pantalla. El targetContentOffset(forProposedContentOffset:)
pregunta dónde detenerse si a la velocidad actual se detendría en proposedContentOffset
.
El cálculo es simple: mira en qué pizza caerá el contenido del contenido proposedContentOffset
y desplázate para que quede en el 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
tiene dos .normal
desplazamiento: .normal
y .fast
. .fast
más adecuado para .fast
:
collectionView!.decelerationRate = .fast
Pero hay un problema: si nos desplazamos solo un poco, entonces debemos quedarnos en la pizza y no pasar al siguiente. No hay ningún método para cambiar la velocidad, por lo que el rebote inverso, aunque a poca distancia, pero a una velocidad muy alta:

¡Atención, pirateo!
Si la celda no cambia, devolvemos el contentOffset
actual, por lo que el desplazamiento se detiene. Luego, nosotros mismos nos desplazaremos al lugar anterior usando el scrollToItem
estándar. Por desgracia, también tiene que desplazarse de forma asincrónica, de modo que el código se llame después del return
, luego habrá poca atenuación durante la animación.
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
El problema se ha ido, ahora la pizza regresa sin problemas:

Aumentar la pizza central
Contamos el diseño al mover
Es necesario hacer que la pizza central aumente gradualmente a medida que se acerca al centro. Para hacer esto, necesita calcular los parámetros no una vez al inicio, sino cada vez en el desplazamiento. Se enciende simplemente:
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { return true }
Ahora, con cada desplazamiento, se layoutAttributesForElements(in:)
métodos prepare
y layoutAttributesForElements(in:)
. Por lo tanto, podemos actualizar UICollectionViewLayoutAttributes
muchas veces seguidas, cambiando suavemente la posición y la transparencia.
En el diseño de la tabla, las celdas estaban una debajo de la otra y sus coordenadas se contaron una vez. Ahora los cambiaremos dependiendo de la posición relativa al centro de la pantalla. Agregue un método que los cambie sobre la marcha.
En el método layoutAttributesForElements
, debe obtener los atributos de la superclase, filtrar los atributos de las celdas y pasarlos al 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) }
Ahora cambiaremos los atributos de la celda en una función:
private func updateCells(_ cells: [UICollectionViewLayoutAttributes])
Durante el movimiento, necesitamos cambiar la transparencia, el tamaño y mantener la pizza en el centro.
La posición de la celda con respecto al centro de la pantalla se presenta convenientemente en una forma normalizada. Si la celda está en el centro, entonces el parámetro es 0, si se desplaza, entonces el parámetro cambia de -1 cuando se mueve hacia arriba, a 1 cuando se mueve. Si los valores están más lejos de cero que 1 / -1, entonces esto significa que la celda ya no es central y ha dejado de cambiar. Llamé a este parámetro de escala:

Necesita calcular la diferencia entre el centro del marco y el centro de la pantalla. Dividiendo la diferencia por una constante, normalizamos el valor, y min y max conducirán a un rango 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
Tamaño
Tener una scale
normalizada, puede hacer cualquier cosa. Los cambios de -1 a +1 son demasiado fuertes, necesitan ser convertidos por tamaño. Por ejemplo, queremos que el tamaño disminuya a un máximo de 0.6 del tamaño de la 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
cambia el tamaño en relación con el centro de las celdas. La celda central tiene normScale = 0, su tamaño no cambia:

Transparencia
La transparencia se puede cambiar a través del parámetro alpha
. El valor de scale
que usamos en la transform
también es adecuado.
cell.alpha = scale
Ahora la pizza cambia su tamaño y transparencia. Ya no es tan aburrido como en las mesas normales.

Bisectando
Antes de eso, trabajamos con una pizza: establecimos el sistema de referencia desde el centro, cambiamos el tamaño y la transparencia. Ahora necesitas dividir por la mitad.
Usar una colección para esto es demasiado difícil: necesitará escribir su propio controlador de gestos para cada mitad. Es más fácil hacer dos colecciones, cada una con su propio diseño. Solo que ahora, en lugar de una pizza entera, habrá mitades.
Dos controladores, un contenedor
Casi siempre, UIViewController
una pantalla en varios UIViewController
, cada uno con su propia tarea. Esta vez resultó así:

- El controlador principal: todas las partes están ensambladas en él y el botón "mezclar".
- Controlador con dos contenedores para mitades, una firma central e indicadores de desplazamiento.
- El controlador con un colector (blanco derecho).
- Panel inferior con precio.
Para distinguir entre la mitad izquierda y la derecha, comencé enum
, se almacena en el diseño en la .orientation
.orientation:
enum PizzaHalfOrientation { case left case right func opposite() -> PizzaHalfOrientation { switch self { case .left: return .right case .right: return .left } } }
Desplazamos las mitades al centro
El diseño anterior dejó de hacer lo que esperamos: las mitades comenzaron a desplazarse al centro de sus colecciones, no al centro de la pantalla:

La solución es simple: debe desplazar horizontalmente las celdas hasta la mitad del centro de la pantalla:
func centerAlignedFrame(for element: UICollectionViewLayoutAttributes, scale: CGFloat) -> CGRect { let hOffset = horizontalOffset(for: element, scale: scale) switch orientation { case .left:
La distancia entre las mitades se controla de inmediato.

Desplazamiento de celda
Era fácil colocar la pizza redonda en un cuadrado, y para la mitad se necesita medio cuadrado:

Puede reescribir el cálculo de cuadros: reducir a la mitad el ancho, alinear los cuadros con el centro de manera diferente para cada mitad. Para simplificar, simplemente cambie el contentMode
imagen dentro de la celda:
class PizzaHalfCell: UICollectionViewCell { var halfOrientation: PizzaHalfOrientation = .left { didSet { imageView?.contentMode = halfOrientation == .left ? .topRight : .topLeft self.setNeedsLayout() } } }

Presione la pizza verticalmente
Las pizzas disminuyeron, pero la distancia entre sus centros no cambió, aparecieron grandes brechas. Puede compensarlos de la misma manera que alineamos las mitades en el 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 las compensaciones se ven así:
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:
Y la configuración de la celda es así:
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 } }
No confunda: la configuración del marco debe ser anterior a la transformación. Si cambia el orden, el resultado de los cálculos será completamente diferente.
Hecho Cortamos las mitades y las alineamos al centro:

Añadir subtítulos
Los encabezados se crean de la misma manera que las celdas, solo que en lugar de UICollectionViewLayoutAttributes(forCellWith:)
, debe usar el constructor UICollectionViewLayoutAttributes(forSupplementaryViewOfKind:)
y devolverlos junto con los parámetros de celda en layoutAttributesForElements(in:)
Primero, describimos un método para obtener un encabezado por 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 }
El cálculo del marco está oculto en el método defaultFrameForHeader
(más adelante).
Ahora puede obtener el IndexPath
celdas visibles y mostrar las IndexPath
para ellas:
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { … let visiblePaths = cells.map { $0.indexPath } let headers = self.headers(for: visiblePaths) updateHeaders(headers) return cells + headers }
Una llamada de función terriblemente larga está oculta en los headers(for:)
método headers(for:)
:
func headers(for paths: [IndexPath]) -> [UICollectionViewLayoutAttributes] { let headers: [UICollectionViewLayoutAttributes] = paths.map { layoutAttributesForSupplementaryView( ofKind: UICollectionView.elementKindSectionHeader, at: $0) }.compactMap { $0 } return headers }
zIndex
Ahora las celdas y las firmas están al mismo nivel de "altura", por lo que pueden superponerse entre sí. Para mantener los encabezados siempre más altos, dele zIndex
mayor que cero. Por ejemplo, 100.
Arreglamos una posición (en realidad no)
Las firmas fijadas en la pantalla son un poco desconcertantes. Quiere arreglar, pero viceversa, moverse constantemente con bounds
:

Todo es simple en el código: obtenemos la posición de la firma en la pantalla y la contentOffset
a 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) }
La altura de las firmas puede ser diferente, es mejor considerarla en el delegado (y en el caché allí).
Firmas animadas
Todo es muy similar a las células. Según la scale
actual, puede calcular la transparencia de la celda. El desplazamiento se puede establecer a través de .transform
, por lo que la inscripción se desplazará en relación con su marco:
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) } }

Optimizar
Después de agregar encabezados, el rendimiento bajó drásticamente. Sucedió porque UICollectionViewLayoutAttributes
las firmas, pero aún las UICollectionViewLayoutAttributes
a UICollectionViewLayoutAttributes
. A partir de esto, los encabezados se agregan a la jerarquía, participan en el diseño, pero no se muestran. Mostramos solo las celdas que se cruzan con los bounds
actuales, y los encabezados deben filtrarse por alpha
:
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { … let visibleHeaders = headers.filter { $0.alpha > 0 } return cells + visibleHeaders }
Estamos de acuerdo con la firma central (receta original)
Hicimos un gran trabajo, pero había una contradicción en la interfaz: si elige dos mitades idénticas, se convierten en una pizza normal.
Decidimos dejarlo así, pero procesamos correctamente la condición, mostrando que era una pizza normal. Nuestra nueva tarea es mostrar una etiqueta en el centro para pizzas idénticas, y esconderse a lo largo de los bordes.

El diseño solo es demasiado difícil de resolver, porque la inscripción se encuentra en la unión de dos coleccionistas. Será más fácil si el controlador, que contiene ambas colecciones, coordina el movimiento de todas las firmas.
Al desplazarse, pasamos el índice actual al controlador, envía el índice a la mitad opuesta. Si los índices coinciden, entonces muestra el título de la pizza original, y si es diferente, entonces las firmas para cada mitad son visibles.
Cómo inventar tus diseños
La parte más difícil fue descubrir cómo poner su idea en la informática. Por ejemplo, quiero que las pizzas rueden como un tambor. Para entender el problema, pasé por 4 pasos habituales:
- Dibujé un par de estados.
- Comprendí cómo los elementos están relacionados con la posición de la pantalla (los elementos se mueven en relación con el centro de la pantalla).
- Variables creadas con las que es conveniente trabajar (centro de la pantalla, marco central de pizza, escala).
- Se me ocurrieron pasos simples, cada uno de los cuales se puede verificar.
Los estados y las animaciones son fáciles de dibujar en Keynote. Tomé el diseño estándar y dibujé los dos primeros pasos:

El video resulta así:

Tomó tres cambios:
- En lugar de marcos del caché, tomaremos
centerPizzaFrame
. - Usando la
scale
lea el desplazamiento de este marco. Recalcular 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:
, zIndex
. : , , 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
, , :
row: zIndex` 0: 0 1: 1 2: 2 3: 10 — 4: 5 5: 4 6: 3 7: 2 8: 1 9: 0
, .
, : , . : , .
, :
- ,
- , : , ,
- ,
- ,
- -,
- «»,
- Voice Over.
:
github , .
UICollectionViewLayout
, A Tour of UICollectionView
, .