[SwiftUI] - Foreach : modification dynamique d'une View

DrakenDraken Membre
25 mai modifié dans API SwiftUI #1

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 to Identifiable or use ForEach(_:id:content:) and provide an explicit id!

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

  • RenaudRenaud Membre
    25 mai modifié #2

    ForEach peut être initialisé de plusieurs manières. Si tu as un Range<Int> comme argument, tu utiliseras l'initialiseur documenté ici, qui doit prendre un Range 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

  • DrakenDraken Membre

    @Ristretto a dit :

    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 ?

    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.

  • DrakenDraken Membre

    @Renaud a dit :
    ForEach peut être initialisé de plusieurs manières. Si tu as un Range<Int> comme argument, tu utiliseras l'initialiseur documenté ici, qui doit prendre un Range constant. Si tu mets le tableau de cases, c'est un autre initialiseur qui est utilisé, et qui ne demande pas une constante.

    Oki, donc c'est normal, juste pas très intuitif pour un noob. Merci.

  • DrakenDraken Membre

    Et voici le gagnant :

    struct GrilleView4 : View {
      @Binding var cases:[DescriptionCase]
      var body : some View {
        VStack {
          ForEach(0..<cases.count, id: \.self) { index in
            CaseView(description: self.cases[index])
          }
        }
      }
    }
    

    Utilisation d'un Index, et gestion dynamique des items, il a tout pour lui !

  • PyrohPyroh Membre

    Non, même si ça marche c'est une mauvaise habitude à prendre que de faire ça.

    Idéalement DescriptionCase est Identifiable et tu utilise ForEach(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 :

    extension Array {
        mutating func removeRandomElement() {
            guard !isEmpty else { return }
            let index = (0..<count).randomElement()!
            remove(at: index)
        }
    }
    
    struct DescriptionCase : Identifiable {
        let id = UUID()
        let couleur: Color = [Color.primary, .gray, .red, .green, .blue, .orange, .yellow, .pink, .purple].randomElement()!
    }
    
    struct CaseView : View {
        var description : DescriptionCase
        var body: some View {
            Rectangle()
                .foregroundColor(description.couleur)
                .frame(width: 200, height: 50)
                .transition(AnyTransition.slide)
        }
    }
    
    struct GrilleView : View {
        @Binding var newMethod: Bool
    
        var cases:[DescriptionCase]
        var body : some View {
            VStack {
                if newMethod {
                    ForEach(cases) {
                        CaseView(description: $0)
                    }
                } else {
                    ForEach(0..<cases.count, id: \.self) { index in
                        CaseView(description: self.cases[index])
                    }
                }
            }
        }
    }
    
    struct ContentView: View {
        @State var newMethod = false
        @State var cases = [DescriptionCase(),
                            DescriptionCase()]
    
        var body: some View {
            VStack {
                Toggle(isOn: $newMethod, label: { Text(verbatim: "Utiliser la nouvelle méthode") })
                    .padding()
                Spacer()
                GrilleView(newMethod: $newMethod, cases: cases)
                Spacer()
                HStack(spacing: 20) {
                    Button(action: { withAnimation { self.cases.removeRandomElement() } })
                    {
                        Image(systemName: "minus.circle.fill")
                            .foregroundColor(.red)
                    }
                    .disabled(cases.isEmpty)
                    Button(action: { withAnimation { self.cases.reverse() } })
                    {
                        Image(systemName: "arrow.up.arrow.down.circle.fill")
                    }
                    Button(action: { withAnimation { self.cases.append(DescriptionCase()) } })
                    {
                        Image(systemName: "plus.circle.fill")
                            .foregroundColor(.green)
                    }
                }
                .font(.system(size: 50))
            }
        }
    }
    
    struct ContentView_Previews: PreviewProvider {
        static var previews: some View {
            ContentView()
        }
    }
    

    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 😉

  • DrakenDraken Membre
    27 mai modifié #8

    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 !

  • PyrohPyroh Membre

    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.

  • DrakenDraken Membre

    @Pyroh a dit :
    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...

    Alala, on n'est pas aidé par la technique !

    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.

    WWDC (virtuel) dans un mois. SwiftUI 2.0 devrait sortir à ce moment. Croisons les doigts !

    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'est clair.

    À 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.

    L'avenir c'est maintenant (ou presque) !

  • DrakenDraken Membre
    29 juin modifié #11

    @Pyroh a dit :
    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...

    Je viens de tester avec la bêta d'XCode 12 (simulateur, pas Device) Cela ne plante plus en utilisant le bouton toggle (ouf !).

    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.

    La gestion des animations est toujours différente selon le type de ForEach, même avec SwiftUI 2.0. Donc, toujours priorité au ForEach(tableau), comme tu me l'as conseillé.

  • DrakenDraken Membre

    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 :

    [1288:131772] invalid mode 'kCFRunLoopCommonModes' provided to CFRunLoopRunSpecific - break on _CFRunLoopError_RunCalledWithInvalidMode to debug. This message will only appear once per execution.

    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.

Connectez-vous ou Inscrivez-vous pour répondre.