Comment créer un conteneur SwiftUI généralisé qui affiche les sous-vues dans une grille hexagonale.
Le composant que nous sommes sur le point de fabriquer est disponible en tant que Forfait Rapide.
SwiftUI est vraiment bon pour construire une hiérarchie de cadres rectangulaires. Avec l’ajout récent de Grid
c’est devenu encore mieux. Cependant, aujourd’hui, nous voulons construire une disposition hexagonale folle. Bien sûr, il n’y a pas de type de mise en page dédié pour cela. Nous construisons donc le nôtre avec le Layout
protocole!
Définissons d’abord une forme pour notre cellule de grille. Pour cela, nous devons mettre en place func path(in rect: CGRect) -> Path
satisfaire Shape
exigence protocolaire. Nous devons essentiellement trouver la plus grande taille d’un hexagone qui rentre dans le rectangle, calculer ses sommets et tracer des lignes entre eux. Voici le code complet pour faire un hexagone à sommet plat.
struct Hexagon: Shape {
static let aspectRatio: CGFloat = 2 / sqrt(3)func path(in rect: CGRect) -> Path {
var path = Path()
let center = CGPoint(x: rect.midX, y: rect.midY)
let width = min(rect.width, rect.height * Self.aspectRatio)
let size = width / 2
let corners = (0..<6)
.map {
let angle = -CGFloat.pi / 3 * CGFloat($0)
let dx = size * cos(angle)
let dy = size * sin(angle)
return CGPoint(x: center.x + dx, y: center.y + dy)
}
path.move(to: corners[0])
corners[1..<6].forEach { point in
path.addLine(to: point)
}
path.closeSubpath()
return path
}
}
Nous devrons placer nos hexagones quelque part. Et pour cela, nous avons besoin d’un système de coordonnées. Le plus facile à comprendre est le système de coordonnées décalées, mais d’autres coordonnées pourraient être utilisées avec le même succès (par exemple, les coordonnées axiales). Nous prendrons une variation q impaire des coordonnées de décalage. Il définit simplement les cellules comme des paires de lignes et de colonnes. Et chaque colonne impaire est décalée de 1/2 vers le bas. Nous devrons fournir ces coordonnées au système de mise en page et cela se fait en créant une clé conforme à LayoutValueKey
.
struct OffsetCoordinate: Hashable {
var row: Int
var col: Int
}protocol OffsetCoordinateProviding {
var offsetCoordinate: OffsetCoordinate { get }
}
struct OffsetCoordinateLayoutValueKey: LayoutValueKey {
static let defaultValue: OffsetCoordinate? = nil
}
Le protocole a 2 exigences :
sizeThatFits
contrôle l’espace dont la vue a besoinplaceSubviews
contrôle le placement des sous-vues dans l’espace disponible
Et éventuellement :
makeCache
pour éviter des calculs supplémentaires
Définissons nos données mises en cache pour le protocole de mise en page. Tout d’abord, nous aurons besoin de connaître les coordonnées en haut à gauche de la grille pour calculer correctement les décalages à partir du coin supérieur gauche des limites. Ensuite, nous aurons besoin de savoir quelle est la taille de la grille en termes de lignes complètes et de colonnes de cellules.
struct CacheData {
let offsetX: Int
let offsetY: Int
let width: CGFloat
let height: CGFloat
}func makeCache(subviews: Subviews) -> CacheData? {
let coordinates = subviews.compactMap { $0[OffsetCoordinateLayoutValueKey.self] }
if coordinates.isEmpty { return nil }
let offsetX = coordinates.map { $0.col }.min()!
let offsetY = coordinates.map { $0.row }.min()!
let coordinatesX = coordinates.map { CGFloat($0.col) }
let minX: CGFloat = coordinatesX.min()!
let maxX: CGFloat = coordinatesX.max()!
let width = maxX - minX + 4 / 3
let coordinatesY = coordinates.map { CGFloat($0.row) + 1 / 2 * CGFloat($0.col & 1) }
let minY: CGFloat = coordinatesY.min()!
let maxY: CGFloat = coordinatesY.max()!
let height = maxY - minY + 1
return CacheData(offsetX: offsetX, offsetY: offsetY, width: width, height: height)
}
Celui-ci est assez simple. Nous avons juste besoin de prendre la largeur de la cellule hexadécimale de manière à ce qu’elle tienne dans la proposition. Et puis multipliez-le par la largeur et la hauteur correspondantes de la grille en termes de largeur de cellule.
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheData?) -> CGSize {
guard let cache else { return .zero }let size = proposal.replacingUnspecifiedDimensions()
let step = min(size.width / cache.width, size.height / cache.height / Hexagon.aspectRatio)
return CGSize(width: step * cache.width, height: step * cache.height * Hexagon.aspectRatio)
}
Ici, nous calculons le pas entre les hexagones suivants. Et puis placer chaque hexagone à sa place correspondante avec la bonne taille.
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheData?) {
guard let cache else { return }let size = proposal.replacingUnspecifiedDimensions()
let step = min(size.width / cache.width, size.height / cache.height / Hexagon.aspectRatio)
let width = step * 4 / 3
let proposal = ProposedViewSize(width: width, height: width / Hexagon.aspectRatio)
let x = width / 2 + bounds.minX
let y = width / Hexagon.aspectRatio / 2 + bounds.minY
for subview in subviews {
guard let coord = subview[OffsetCoordinateLayoutValueKey.self] else { continue }
let dx: CGFloat = step * CGFloat(coord.col - cache.offsetX)
let dy: CGFloat = step * Hexagon.aspectRatio * (CGFloat(coord.row - cache.offsetY) + 1 / 2 * CGFloat(coord.col & 1))
let point = CGPoint(x: x + dx, y: y + dy)
subview.place(at: point, anchor: .center, proposal: proposal)
}
}
À ce stade, le HexLayout
est déjà utilisable. Cependant, la règle selon laquelle toutes les sous-vues doivent avoir une coordonnée n’est pas appliquée. Il est donc préférable de faire un wrapper mince qui fournira cette garantie de temps de compilation aux consommateurs de composants. Pendant que nous y sommes, nous allons découper les sous-vues avec la forme de l’hexagone pour rendre le site d’appel encore plus propre.
struct HexGrid<Data, ID, Content>: View where Data: RandomAccessCollection, Data.Element: OffsetCoordinateProviding, ID: Hashable, Content: View {
let data: Data
let id: KeyPath<Data.Element, ID>
let content: (Data.Element) -> Contentinit(_ data: Data,
id: KeyPath<Data.Element, ID>,
@ViewBuilder content: @escaping (Data.Element) -> Content) {
self.data = data
self.id = id
self.content = content
}
var body: some View {
HexLayout {
ForEach(data, id: id) { element in
content(element)
.clipShape(Hexagon())
.layoutValue(key: OffsetCoordinateLayoutValueKey.self,
value: element.offsetCoordinate)
}
}
}
}
extension HexGrid where ID == Data.Element.ID, Data.Element: Identifiable {
init(_ data: Data,
@ViewBuilder content: @escaping (Data.Element) -> Content) {
self.init(data, id: \.id, content: content)
}
}
Nous pouvons enfin définir notre modèle de données et utiliser le composant prêt pour obtenir l’image du début de l’article :
struct HexCell: Identifiable, OffsetCoordinateProviding {
var id: Int { offsetCoordinate.hashValue }
var offsetCoordinate: OffsetCoordinate
var colorName: String
}let cells: [HexCell] = [
.init(offsetCoordinate: .init(row: 0, col: 0), colorName: "color1"),
.init(offsetCoordinate: .init(row: 0, col: 1), colorName: "color2"),
.init(offsetCoordinate: .init(row: 0, col: 2), colorName: "color3"),
.init(offsetCoordinate: .init(row: 1, col: 0), colorName: "color4"),
.init(offsetCoordinate: .init(row: 1, col: 1), colorName: "color5")
]
HexGrid(cells) { cell in
Color(cell.colorName)
}
Mais vous pouvez mettre des images ou littéralement n’importe quelle vue dans des sous-vues ! Sachez simplement que la mise en page suppose que les sous-vues remplissent le contenu de la cellule hexagonale.
HexGrid(cells) { cell in
AsyncImage(url: cell.url) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
Nous avons appris à fournir des valeurs à LayoutSubview
proxy et créez une mise en page amusante et non triviale.
Pour plus d’informations sur les grilles hexagonales, voir ceci guide fantastique