[SwiftUI] - Foreach : modification dynamique d'une View
Je suis tombé par hasard sur un problème qui m'étonne un peu. Je sais comment le régler, mais le pourquoi m'interpelle. Est-ce une mauvaise compréhension de ma part, ou un problème technique lié à la jeunesse de SwiftUI ?
J'ai écris une petite application pour tester la modification dynamique d'une View avec Foreach:
import SwiftUI
struct DescriptionCase : Identifiable {
var id = UUID()
var couleur = Color.green
}
struct CaseView : View {
var description : DescriptionCase
var body: some View {
Rectangle()
.foregroundColor(description.couleur)
.frame(width: 200, height: 50)
}
}
struct GrilleView : View {
var cases:[DescriptionCase]
var body : some View {
VStack {
ForEach(0..<cases.count) { index in
CaseView(description: self.cases[index])
}
}
}
}
struct ContentView: View {
@State var cases = [DescriptionCase(),
DescriptionCase()]
var body: some View {
VStack {
Spacer()
GrilleView(cases: cases)
//
Spacer()
Button(action: {
self.cases.append(DescriptionCase())})
{
Text("Ajouter Case")
.font(.largeTitle)
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Rien de compliqué : une structure décrivant un rectangle de couleur, un tableau, et un composant SwiftUI dessinant une grille verticale à partir d'une liste. Un bouton permet d'ajouter un objet dans la liste des objets actifs.
Le principe est simple, le composant ViewGrille utilise l'instruction Foreach pour construire l'affichage à partir d'une liste d'objets stocké dans un tableau de type @State. Théoriquement, il suffit juste d'ajouter un élément au tableau, pour que SwiftUI redessine l'écran.
Au lancement, ça fonctionne. J'ai bien les deux rectangles définis dans le tableau :
Mais il suffit de cliquer sur le bouton pour avoir un joli plantage :
Le message semble indiquer qu'il s'agit d'une erreur d'identification du descripteur de rectangle :
ForEach<Range, Int, CaseView> count (3) != its initial count (2).
ForEach(_:content:)
should only be used for constant data. Instead conform data toIdentifiable
or useForEach(_:id:content:)
and provide an explicitid
!
Je fournis pourtant une structure identifiable, avec un identifiant unique.
Curieusement, cela fonctionne en utilisant Foreach pour énumérer le tableau, au lieu de passer par un index de boucle.
struct GrilleView2 : View {
var cases:[DescriptionCase]
var body : some View {
VStack {
ForEach (cases) { item in
CaseView(description: item)
}
}
}
}
Je ne vois pas trop la différence entre les deux écritures, pourtant l'une fonctionne et l'autre non :
// Ça marche pas !
ForEach(0..<cases.count) { index in
CaseView(description: self.cases[index])
// Ça marche
ForEach (cases) { item in
CaseView(description: item)
Une idée, docteurs ?
Réponses
ForEach
peut être initialisé de plusieurs manières. Si tu as unRange<Int>
comme argument, tu utiliseras l'initialiseur documenté ici, qui doit prendre unRange
constant. Si tu mets le tableau de cases, c'est un autre initialiseur qui est utilisé, et qui ne demande pas une constante.Bonsoir,
Je n'ai pas testé, mais ne faudrait-il pas que la variable cases de GrilleView ait un lien de type Binding avec la variable cases de ContentView ?
(et tu devrais appeler ces deux variables de deux noms différents au moins pour tester et éviter toute ambiguïté)
Cordialement,
Nicolas
Non, le (dys)fonctionnement est le même que j'utilise le @Binding ou pas. Ma première version utilisais un @Binding sur le tableau cases, que j'ai retiré ensuite pour tester différentes syntaxes.
Oki, donc c'est normal, juste pas très intuitif pour un noob. Merci.
Et voici le gagnant :
Utilisation d'un Index, et gestion dynamique des items, il a tout pour lui !
Non, même si ça marche c'est une mauvaise habitude à prendre que de faire ça.
Idéalement
DescriptionCase
estIdentifiable
et tu utiliseForEach(cases)...
.Le pourquoi est assez compliqué et c'est lié à la manière dont SwiftUI identifie les vues et met à jour le view tree. Ici tu identifie l'index de la vue ce qui risque d'introduire des bugs que tu vas avoir du mal à fixer. Surtout quand tu vas essayer d'animer tes changements. Sache juste que c'est comme ça qu'il faut faire™.
Aussi attention à l'utilisation d'un UUID comme ID, c'est pas gratuit la génération d'un UUID. Ici c'est pas grave mais il vaut mieux utiliser une propriété unique et
Hashable
de ta structure.J'ai un peu modifié le sample que tu donne au début du thread :
Teste-le. Le bouton vert ajoute une case en bas de la pile, le bouton rouge retire une case au hasard et le bouton bleu inverse l'ordre de la pile. Le switch "Utiliser la nouvelle méthode" te montre la différence de comportement entre les deux méthodes pour le
ForEach
.La nouvelle méthode est la mienne l'autre est la tienne, tu vas voir la différence et comprendre pourquoi il vaut mieux utiliser la nouvelle méthode 😉
Effectivement, il y a une sacrée différence entre les deux méthodes. Cela saute aux yeux en pressant sur le toggle. 😇
La première fonctionne et l'autre .. euh ..
Bon, je suis mauvaise langue. La seconde méthode fonctionne aussi. Je l'ai testé en inversant le flag booléen dans le code. C'est la transition avec le toggle qui fait planter l'application.
Effectivement, la différence est visible, avec l'animation de disparition. Très sympa tes animations !
J'avais oublié de préciser qu'il fallait tester sur device directement sinon ça marchait pas. Les transitions c'est une catastrophe à faire fonctionner...
Pour les animations c'est du standard SwiftUI. Mais encore une fois les transitions sont super buggées et attendre SwiftUI V2.0 (ou V1.0 selon les mauvaises langues) est une bonne idée avant de baser son app dessus.
L'important ici était de mettre en lumière la bonne utilisation du
ForEach
. Sans animation ta solution est fonctionnelle mais il vaut mieux écrire du code que tu pourras animer à l'envie 😃À côté de ça c'est super intéressant de travailler avec SwiftUI une fois que tu as compris qu'il ne faut pas l'utiliser comme UIKit ou AppKit c'est un bonheur. En gros tu décris comment les choses doivent être et le framework les crée et les manipule.
Alala, on n'est pas aidé par la technique !
WWDC (virtuel) dans un mois. SwiftUI 2.0 devrait sortir à ce moment. Croisons les doigts !
C'est clair.
L'avenir c'est maintenant (ou presque) !
Je viens de tester avec la bêta d'XCode 12 (simulateur, pas Device) Cela ne plante plus en utilisant le bouton toggle (ouf !).
La gestion des animations est toujours différente selon le type de
ForEach
, même avec SwiftUI 2.0. Donc, toujours priorité auForEach(tableau)
, comme tu me l'as conseillé.Rectification : il y a toujours un problème en utilisant le toggle. Cela ne plante plus, mais il y a un message d'erreur dans la console :
Alala, on n'est pas aidé par la technique ..
Enfin c'est juste une bêta. On verra avec la version finale d'XCode 12.
À l'envi sans "e" dans ce cas.