Réaliser un Layout custom en SwiftUI

... un guide simple pour créer un layout flexible et réutilisable en SwiftUI

Un custom layout en SwiftUI

Les HStack et les VStack sont une des pierres angulaires de tout design en SwiftUI.

Mais quand les designers s'emballent, on a besoin de plus.

Dans ces moments-là, il est tentant de partir sur un patchwork qui mélange les deux mais qui finit souvent en plat de spaghettis.

Pourtant Apple a pensé à tout.

Est-ce que tu savais que derrière ces composants se cache le protocole Layout que l’on peut implémenter nous-même pour donner vie à n’importe quel design ?

Dans cet article, je te montre comment créer un FlowLayout semblable à ce qu’on pourrait faire avec une CollectionView mais en 100% SwiftUI.

Si tu ne vois pas à quoi ça correspond, imagine un nuage de mots-clés ou bien des tags à faire tenir à la suite comme dans Mail.

Pourquoi se pencher sur le sujet

SwiftUI nous fournit déjà pas mal de composants permettant de structurer nos layout. Entre les HStack, VStack, Grid, leurs versions Lazy et autres, on a de quoi faire.

Mais parfois pour réaliser certains designs, ils ne suffisent pas. Prenez l'exemple d'un design où les éléments sont placés circulairement autour d'un point, un peu comme les graduations d'une horloge ou encore placés sur une sinusoïde (on va pas se mentir, certains designers ont l'imagination débordante).

Dans ces moments-là, même en combinant les éléments natifs dans tous les sens, c'est pas évident à implémenter ou alors le code ressemble vite à pas grand chose.

C'est pour des cas comme ça que Apple a mis à notre disposition le protocol Layout.

Ce protocol a deux méthodes obligatoires que l'on doit implémenter.

  • sizeThatFits(proposal:subviews:cache:) :

Cette méthode est responsable de déterminer la taille de notre container. Elle est nécessaire pour dire à son parent la taille dont il a besoin.

  • placeSubviews(in:proposal:subviews:cache:) :

Cette méthode est responsable de déterminer la position de chaque subview.

Il est important de noter qu'il est possible que les subviews soient placés en dehors du container si les calculs dans l'une de ces deux méthodes sont faux.

Pas besoin de se coltiner toute la doc Apple : voyons comment l’utiliser concrètement.

Comment ça s'implémente

On commence par déclarer notre layout custom :

import SwiftUI

struct FlowLayout: Layout {
    ...
}

Et on ajoute les méthodes obligatoires pour que le compilo arrête de nous hurler dessus :

func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
    // A remplir
    return .zero
}
    
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
    // A remplir
}

Et pour finir le setup, tu peux ajouter cette preview à la fin du fichier pour visualiser l'avancement :

#Preview {
    let backgrounds: [Color] = [.blue, .red, .green]
    FlowLayout {
        ForEach(0..<20) { index in
            let str = Array(repeating: String(index), count: index + 1).joined()
            Text(str)
                .background(backgrounds.randomElement()!)
        }
    }
    .background(.gray)
    .padding()
}

Si tu te demandes ce que cette preview va afficher, c'est simplement l'index courant plusieurs fois. Les backgrounds eux nous serviront à vérifier nos changements. Pour mieux te rendre compte, tu peux remplacer le FlowLayout par une VStack.

À ce stade, comme tu peux le voir, ça ressemble pas encore à grand chose ...

Version basique du layout

Dans un premier temps on va réaliser une version avec les éléments alignés à gauche, sans espacement entre les éléments, le minimum syndical quoi.

Calcul de la taille du layout

L'objectif est de calculer la taille de notre layout en se basant sur la taille fournie par le parent et les tailles des différentes subviews.

Voici ce que ça donne :

func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
    let safeProposal = proposal.replacingUnspecifiedDimensions() // 1
    
    let subviewSizes = subviews
        .map { $0.sizeThatFits(.init(width: safeProposal.width, height: .infinity)) } // 2
    
    var containerHeight = CGFloat.zero
    var linesMaxWidth = CGFloat.zero
    
    var currentLineSize = CGSize.zero // 3
    
    subviewSizes.forEach { size in // 4
        let lineNextWidth = currentLineSize.width + size.width
        let isSubviewOverflowing = lineNextWidth > safeProposal.width
        
        if isSubviewOverflowing {
            linesMaxWidth = max(linesMaxWidth, currentLineSize.width)
            containerHeight += currentLineSize.height
            
            currentLineSize = .zero
        }
        
        currentLineSize.width = currentLineSize.width + size.width
        currentLineSize.height = max(currentLineSize.height, size.height)
    }

    containerHeight += currentLineSize.height // 5
    
    return CGSize(width: linesMaxWidth, height: containerHeight) // 6
}

Un peu d'explication :

  1. Il est possible que la taille proposée par le parent contienne des infinity, pour palier à ça replacingUnspecifiedDimensions() les remplace par des valeurs finies.
  1. On convertit les subviews en leurs dimensions en spécifiant que la width maximale est celle du parent (pour éviter que notre subview soit plus large) et en ne spécifiant pas de hauteur maximale.
  1. On initialise les différentes variables qui seront utilisées ensuite pour faire nos calculs.
  1. C'est dans cette boucle que les calculs se font. On commence par vérifier que l'élément courant a assez d'espace pour être ajouté sur la ligne actuelle, si ce n'est pas le cas, on ajoute une ligne et on reset le size de la ligne courante.
  1. On oublie pas la dernière ligne, qui n'a pas été ajoutée car elle n'a pas déclenché d'overflow.
  1. Enfin on renvoie la taille de notre container : la width de la plus longue ligne et la height cumulée.

La preview devrait donner un truc du style :

Placement des subviews

Maintenant que notre layout a une taille qui ressemble à quelque chose, finalisons notre version basique.

L'implémentation donne un truc de ce style :

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
    let safeProposal = proposal.replacingUnspecifiedDimensions() // 1
    
    var currentSubviewOffset = bounds.origin // 2
    
    subviews.forEach { subview in
        let subviewSize = subview.sizeThatFits(.init(width: safeProposal.width, height: .infinity)
        ) // 3
        
        let nextSubviewXOffset = currentSubviewOffset.x + subviewSize.width
        let isSubviewOverflowing = nextSubviewXOffset > safeProposal.width
        
        if isSubviewOverflowing {
            currentSubviewOffset.x = bounds.minX
            currentSubviewOffset.y += subviewSize.height
        } // 4
        
        subview.place(
            at: currentSubviewOffset,
            proposal: .init(
                width: subviewSize.width,
                height: subviewSize.height.isFinite ? subviewSize.height : nil
            )
        ) // 5
        
        currentSubviewOffset.x += subviewSize.width // 6
    }
}

C'est parti pour les explications :

  1. Comme tout à l'heure, on s'assure que la taille fournie par le parent ne contient pas de valeurs foireuses.
  1. On initialise notre offset avec l'origine du rectangle fournit, surtout ne pas assumer que le parent commence à .zero.
  1. On récupère les dimensions de notre subview.
  1. Si la subview ne rentre pas dans la ligne actuelle, on ajoute une ligne en réinitilisant l'offset horizontalement et en ajoutant la height de notre subview.
  1. La partie la plus importante. Cette méthode vient placer la subview selon les paramètres passés. Ici on lui donne notre offset ainsi qu'une taille, la width déterminée plus haut et concernant la height, sachant qu'on ne lui en a pas fixée avant, on vérifie qu'elle est finie, sinon on précise nil.
  1. Et enfin on décale l'offset horizontalement pour la prochaine subview.

Tu devrais avoir un truc qui ressemble à ça au final :

Résultat de l'étape de placement des subviews du layout

C'est fini ?

Minute papillon, y'a pas mal de choses à revoir dans notre copie :

  • On ne gère actuellement qu'un alignement à gauche, et si on voulait gérer un alignement à droite ou au centre ?
  • Idem pour l'alignement vertical de chaque ligne si les subviews ont des hauteurs différentes, elles sont alignées en haut.
  • Pas moyen de spécifier des espaces entre les éléments ni les lignes.
  • Les implémentations de nos deux méthodes se ressemblent beaucoup et une modification d'un côté entraîne une modification de l'autre.

Réglons ces problèmes, du plus embêtant au plus cosmétique.

Mutualisiation des calculs

Est-ce le plus critique ? Pas forcément.

Est-ce que ça va nous faciliter la vie lors de nos prochaines modifications ? Assurément.

C'est pour ça que c'est le problème à corriger en premier selon moi.

Comme tu as dû sûrement t'en rendre compte, les deux méthodes de notre layout se ressemblent énormément et c'est normal, après tout dans les deux cas on se base sur la taille de nos subviews pour ensuite, dans un cas, calculer la taille globale, dans l'autre, l'offset des subviews.

Voyons comment on peut mutualiser tout ça :

func layout(
    proposal: ProposedViewSize,
    subviews: Subviews
) -> (containerSize: CGSize, offsets: [CGPoint]) {
    let safeProposal = proposal.replacingUnspecifiedDimensions() // 1
        
    // 2
    var offsets = [CGPoint]()
    var currentSubviewOffset = CGPoint.zero
        
    var currentLineHeight = CGFloat.zero
    var containerSize = CGSize.zero
        
    subviews.forEach { subview in
        let subviewSize = subview.sizeThatFits(.init(width: safeProposal.width, height: .infinity)
        ) // 3
            
        let nextSubviewXOffset = currentSubviewOffset.x + subviewSize.width
        let isSubviewOverflowing = nextSubviewXOffset > safeProposal.width
            
        // 4
        if isSubviewOverflowing {
            containerSize.height += currentLineHeight
            currentSubviewOffset.y += currentLineHeight

            currentLineHeight = .zero    
            currentSubviewOffset.x = .zero
        }
            
        // 5
        offsets.append(currentSubviewOffset)
        currentSubviewOffset.x += subviewSize.width
        currentLineHeight = max(currentLineHeight, subviewSize.height)
            
        containerSize.width = max(containerSize.width, currentSubviewOffset.x)
    }
        
    containerSize.height += currentLineHeight // 6
        
    return (containerSize: containerSize, offsets: offsets)
}

On commence par créer une nouvelle méthode appelée layout (parce que j'ai pas trouvé de meilleur nom ...)

J'ai plus ou moins fait un patchwork des deux méthodes :

  1. Comme d'habitude, on récupère une proposal sans valeur foireuse.
  1. On crée les différentes variables dont on va avoir besoin pour la suite.
  1. On récupère la taille de la subview selon notre proposition.
  1. En cas d'overflow, on augmente la hauteur du container et on décale l'offset de la taille de la ligne actuelle. Ensuite on reset l'offset selon X et la hauteur de la ligne.
  1. On ajoute l'offset actuel au tableau, qui correspond à l'origine de notre subview, on décale l'offset selon X, on trouve la hauteur max de la ligne et on agrandit la largeur du container si besoin.
  1. Enfin on oublie pas d'ajouter la hauteur de la dernière ligne.

On peut maintenant remplacer l'implémentation pour le calcul de la taille de notre layout :

func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
    layout(proposal: proposal, subviews: subviews).containerSize
}

Le gain est assez évident ici.

Et on fait pareil pour l'implémentation du placement des subviews :

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
    let safeProposal = proposal.replacingUnspecifiedDimensions()
    let offsets = layout(proposal: proposal, subviews: subviews).offsets
    
    zip(subviews, offsets).forEach { subview, offset in
        let subviewSize = subview.sizeThatFits(.init(width: safeProposal.width, height: .infinity))
        
        subview.place(
            at: .init(
                x: offset.x + bounds.minX,
                y: offset.y + bounds.minY
            ),
            proposal: .init(
                width: subviewSize.width,
                height: subviewSize.height.isFinite ? subviewSize.height : nil
            )
        )
    }
}

Ici la différence notable est l'utilisation du zip qui permet de regrouper une subview avec son offset.

Et TADA, plus besoin de penser à modifier les deux méthodes, ce qui ne faisait pas vraiment de sens quand on y réfléchit puisque les deux sont étroitement liées.


On a un layout qui fonctionne. Mais il est loin d’être souple ou complet.

La suite ? Le rendre plus flexible, plus aligné, plus... SwiftUI.

On verra ça ensemble dans le prochain article.

En attendant si tu as des questions ou des remarques, n'hésite pas à me contacter sur LinkedIn, je me ferai un plaisir de te répondre.

Cheers.

ARTICLE PRÉCÉDENT →