Depois de assistir ao Keynote WWDC 2019 e conhecer o SwiftUI , que é projetado para descrever declarativamente as UIs no código, quero especular sobre como preencher declarativamente tablets e coleções. Por exemplo, assim:
enum Builder { static func widgets(objects: Objects) -> [Widget] { let header = [ Spacing(height: 25).widget, Header(string: " ").widget, Spacing(height: 10, separator: .bottom).widget ] let body = objects .flatMap({ (object: Object) -> [Widgets] in return [ Title(object: object).widget, Spacing(height: 1, separator: .bottom).widget ] }) return header + body } } let objects: [Object] = ... Builder .widgets(objects: objects) .bind(to: collectionView)
Na coleção, isso é renderizado da seguinte maneira:
1. Introdução
Como você sabe de fontes oficiais : na grande maioria do tempo, um desenvolvedor iOS típico gasta trabalhando com tablets. Se assumirmos que os desenvolvedores do projeto estão em falta e os tablets não são simples, não resta absolutamente tempo para o restante do aplicativo. E algo precisa ser feito com isso ... Uma possível solução seria a decomposição celular.
Decomposição celular significa substituir uma célula por várias células menores. Com essa substituição, visualmente nada deve mudar. Como exemplo, considere postagens do feed de notícias VK para iOS. Um post pode ser apresentado como uma única célula ou como um grupo de células - primitivas .
A decomposição de células nem sempre funciona. Será difícil dividir uma célula em primitivas com sombra ou arredondamento por todos os lados. Nesse caso, a célula original será uma primitiva.
Prós e contras
Usando a decomposição de células, as tabelas / coleções começam a consistir em primitivas que frequentemente serão reutilizadas: uma primitiva com texto, uma primitiva com uma imagem, uma primitiva com um plano de fundo etc. O cálculo da altura de uma única primitiva é muito mais simples e mais eficaz do que uma célula complexa com um grande número de estados. Se desejado, a altura dinâmica da primitiva pode ser calculada ou mesmo desenhada em segundo plano (por exemplo, texto via CTFramesetter
).
Por outro lado, trabalhar com dados se torna mais complicado. Os dados serão necessários para cada primitiva e, de acordo com o IndexPath
primitiva, será difícil determinar a qual célula real ela pertence. Teremos que introduzir novas camadas de abstração ou, de alguma forma, resolver esses problemas.
Você pode conversar por um longo tempo sobre os possíveis prós e contras desse empreendimento, mas é melhor tentar descrever a abordagem da decomposição celular.
Escolhendo ferramentas
Como o UITableView
limitado em seus recursos e, como já mencionado, temos placas bastante complexas, uma solução apropriada seria usar o UICollectionView
. É sobre o UICollectionView
que será discutido nesta publicação.
Usando o UICollectionView
depara com uma situação em que o UICollectionViewFlowLayout
base não pode formar a organização desejada dos elementos da coleção (não levamos em consideração o novo UICollectionViewCompositionalLayout
). Nesses momentos, geralmente é tomada a decisão de encontrar algum UICollectionViewLayout
código UICollectionViewLayout
. Mas mesmo entre soluções prontas, pode não haver uma adequada, como, por exemplo, no caso da página principal dinâmica de uma grande loja online ou rede social. Assumimos o pior, portanto, criaremos nosso próprio UICollectionViewLayout
universal.
Além da dificuldade em escolher um layout, você precisa decidir como a coleção receberá dados. Além da abordagem usual, em que um objeto (geralmente um UIViewController
) está em conformidade com o protocolo UICollectionViewDataSource
e fornece dados para uma coleção, o uso de estruturas orientadas a dados está ganhando popularidade. Os representantes brilhantes dessa abordagem são CollectionKit , IGListKit , RxDataSources e outros. O uso de tais estruturas simplifica o trabalho com coleções e fornece a capacidade de animar alterações de dados, porque O algoritmo diffing já está presente na estrutura. Para fins de publicação, a estrutura RxDataSources será selecionada.
Widget e suas propriedades
Introduzimos uma estrutura de dados intermediária e chamamos de widget . Descrevemos as principais propriedades que um widget deve ter:
- O widget deve estar em conformidade com os protocolos necessários para usar a estrutura controlada por dados. Esses protocolos geralmente contêm um valor associado (por exemplo,
IdentifiableType
em RxDataSources ) - Deve ser possível montar widgets para diferentes primitivas em uma matriz. Para conseguir isso, o widget não deve ter valores associados. Para esses fins, você pode usar o mecanismo de apagamento de tipo ou algo parecido.
- O widget deve poder contar o tamanho da primitiva. Em seguida, ao formar o
UICollectionViewLayout
, resta apenas posicionar corretamente as primitivas de acordo com as regras predefinidas. - O widget deve ser a fábrica para o
UICollectionViewCell
. Portanto, a partir da implementação de UICollectionViewDataSource
toda a lógica para criar células será removida e tudo o que resta é:
let cell = widget.widgetCell(collectionView: collectionView, indexPath: indexPath) return cell
Implementação de widget
Para poder usar o widget com a estrutura RxDataSources , ele deve estar em conformidade com os protocolos Equatable
e IdentifiableType
. Como o widget representa uma primitiva, será suficiente para fins de publicação se o widget se identificar para cumprir o protocolo IdentifiableType
. Na prática, isso afetará o fato de que, quando o widget for alterado, a primitiva não será recarregada, mas excluída e inserida. Para fazer isso, introduza o novo protocolo WidgetIdentifiable
:
protocol WidgetIdentifiable: IdentifiableType { } extension WidgetIdentifiable { var identity: Self { return self } }
Para estar em conformidade com o WidgetIdentifiable
, o widget precisa estar em conformidade com o protocolo Hashable
. Hashable
widget Hashable
aceita dados para conformidade com o protocolo do objeto que descreverá uma primitiva específica. Você pode usar o AnyHashable
para "apagar" o widget de tipo de objeto.
struct Widget: WidgetIdentifiable { let underlying: AnyHashable init(_ underlying: AnyHashable) { self.underlying = underlying } } extension Widget: Hashable { func hash(into hasher: inout Hasher) { self.underlying.hash(into: &hasher) } static func ==(lhs: Widget, rhs: Widget) -> Bool { return lhs.underlying == rhs.underlying } }
Nesse estágio, as duas primeiras propriedades do widget são executadas. Não é difícil verificar coletando vários widgets com diferentes tipos de objetos em uma matriz.
let widgets = [Widget("Hello world"), Widget(100500)]
Para implementar as propriedades restantes, introduzimos um novo protocolo WidgetPresentable
protocol WidgetPresentable { func widgetCell(collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionViewCell func widgetSize(containerWidth: CGFloat) -> CGSize }
A função widgetSize(containerWidth:)
será usada no UICollectionViewLayout
ao gerar os atributos das células e widgetCell(collectionView:indexPath:)
- para obter as células.
Se o widget estiver em WidgetPresentable
protocolo WidgetPresentable
, ele cumprirá todas as propriedades indicadas no início da publicação. No entanto, o objeto contido no widget AnyHashable precisará ser substituído pela composição WidgetPresentable
e WidgetHashable
, onde WidgetHashable
não terá um valor associado (como no caso de Hashable
) e o tipo de objeto dentro do widget permanecerá "apagado":
protocol WidgetHashable { func widgetEqual(_ any: Any) -> Bool func widgetHash(into hasher: inout Hasher) }
Na versão final, o widget ficará assim:
struct Widget: WidgetIdentifiable { let underlying: WidgetHashable & WidgetPresentable init(_ underlying: WidgetHashable & WidgetPresentable) { self.underlying = underlying } } extension Widget: Hashable { func hash(into hasher: inout Hasher) { self.underlying.widgetHash(into: &hasher) } static func ==(lhs: Widget, rhs: Widget) -> Bool { return lhs.underlying.widgetEqual(rhs.underlying) } } extension Widget: WidgetPresentable { func widgetCell(collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionViewCell { return underlying.widgetCell(collectionView: collectionView, indexPath: indexPath) } func widgetSize(containerWidth: CGFloat) -> CGSize { return underlying.widgetSize(containerWidth: containerWidth) } }
Objeto primitivo
Vamos tentar montar a primitiva mais simples, que será o recuo de uma determinada altura.
struct Spacing: Hashable { let height: CGFloat } class SpacingView: UIView { lazy var constraint = self.heightAnchor.constraint(equalToConstant: 1) init() { super.init(frame: .zero) self.constraint.isActive = true } } extension Spacing: WidgetHashable { func widgetEqual(_ any: Any) -> Bool { if let spacing = any as? Spacing { return self == spacing } return false } func widgetHash(into hasher: inout Hasher) { self.hash(into: &hasher) } } extension Spacing: WidgetPresentable { func widgetCell(collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionViewCell { let cell: WidgetCell<SpacingView> = collectionView.cellDequeueSafely(indexPath: indexPath) if cell.view == nil { cell.view = SpacingView() } cell.view?.constraint.constant = height return cell } func widgetSize(containerWidth: CGFloat) -> CGSize { return CGSize(width: containerWidth, height: height) } }
WidgetCell<T>
é apenas uma subclasse de UICollectionViewCell
que aceita uma UIView
e a adiciona como uma subview. cellDequeueSafely(indexPath:)
é uma função que registra uma célula em uma coleção antes da reutilização se uma célula da coleção não tiver sido registrada anteriormente. Spacing
será usado conforme descrito no início da publicação.
Depois de receber a matriz de widgets, resta apenas ligar ao observerWidgets
:
typealias DataSource = RxCollectionViewSectionedAnimatedDataSource<WidgetSection> class Controller: UIViewController { private lazy var dataSource: DataSource = self.makeDataSource() var observerWidgets: (Observable<Widgets>) -> Disposable { return collectionView.rx.items(dataSource: dataSource) } func makeDataSource() -> DataSource { return DataSource(configureCell: { (_, collectionView: UICollectionView, indexPath: IndexPath, widget: Widget) in let cell = widget.widgetCell(collectionView: collectionView, indexPath: indexPath) return cell }) } }
Resultados
Concluindo, gostaria de mostrar o verdadeiro trabalho da coleção, que é construída inteiramente em widgets.
Como você pode ver, a decomposição do UICollectionViewCell
viável e, em situações adequadas, pode simplificar a vida do desenvolvedor.
Observações
O código fornecido na publicação é muito simplificado e não deve ser compilado. O objetivo era descrever a abordagem, não fornecer uma solução pronta para uso.
O protocolo WidgetPresentable
pode ser expandido com outras funções que otimizam o layout, por exemplo, widgetSizeEstimated(containerWidth:)
ou widgetSizePredefined(containerWidth:)
, que retornam o tamanho estimado e fixo, respectivamente. Vale ressaltar que a função widgetSize(containerWidth:)
deve retornar o tamanho da primitiva mesmo para cálculos exigentes, por exemplo, para systemLayoutSizeFitting(_:)
. Esses cálculos podem ser armazenados em cache através do Dictionary
, NSCache
, etc.
Como você sabe, todos os tipos de células usados pelo UICollectionView
devem ser pré-registrados na coleção. No entanto, para reutilizar widgets entre diferentes telas / coleções e não registrar todos os tipos / identificadores de célula com antecedência, é necessário adquirir um mecanismo que registre uma célula imediatamente antes de seu primeiro uso em cada coleção. Na publicação, a função cellDequeueSafely(indexPath:)
usada para isso.
Não pode haver cabeçalhos ou rodapés na coleção. Em seu lugar serão primitivos. A presença de suplementação na coleção não dará nenhum bônus especial na abordagem atual. Geralmente eles são usados quando a matriz de dados corresponde estritamente ao número de células e é necessário mostrar visualizações adicionais antes, depois ou entre as células. No nosso caso, os dados para visualizações auxiliares também podem ser adicionados à matriz de widgets e desenhados como primitivos.
Dentro da mesma coleção, widgets com os mesmos objetos podem ser localizados. Por exemplo, o mesmo Spacing
no início e no final da coleção. A presença de tais objetos não exclusivos levará ao fato de que a animação na coleção desaparece. Para tornar esses objetos exclusivos, você pode usar tags AnyHashable
especiais, #file
e #line
onde o objeto foi criado, etc.