web-dev-qa-db-fra.com

SwiftUI: ObservableObject ne persiste pas dans son état après avoir été redessiné

Problème

Afin d'obtenir une apparence et une sensation propres du code de l'application, je crée des ViewModels pour chaque vue qui contient une logique.

Un ViewModel normal ressemble un peu à ceci:

class SomeViewModel: ObservableObject {

    @Published var state = 1

    // Logic and calls of Business Logic goes here
}

et est utilisé comme ceci:

struct SomeView: View {

    @ObservedObject var viewModel = SomeViewModel()

    var body: some View {
        // Code to read and write the State goes here
    }
}

Cela fonctionne correctement lorsque le parent de vues n'est pas mis à jour. Si l'état du parent change, cette vue est redessinée (assez normal dans un framework déclaratif). Mais également le ViewModel est recréé et ne détient pas l'état par la suite. Ceci est inhabituel lorsque vous comparez à d'autres Frameworks (par exemple: Flutter).

À mon avis, le ViewModel devrait rester, ou l'état devrait persister.

Si je remplace le ViewModel par un @State Propriété et utilisez la int (dans cet exemple) directement, elle reste persistante et ne se recrée pas:

struct SomeView: View {

    @State var state = 1

    var body: some View {
        // Code to read and write the State goes here
    }
}

Cela ne fonctionne évidemment pas pour les États plus complexes. Et si je définis une classe pour @State (comme le ViewModel) de plus en plus Les choses ne fonctionnent pas comme prévu.

Question

  • Existe-t-il un moyen de ne pas recréer le ViewModel à chaque fois?
  • Existe-t-il un moyen de répliquer le @State Propertywrapper pour @ObservedObject?
  • Pourquoi @State garde-t-il l'état sur le redessiner?

Je sais qu'habituellement, il est mauvais de créer un ViewModel dans une vue intérieure, mais ce comportement peut être répliqué à l'aide d'un NavigationLink ou d'une feuille.
Parfois, il n'est alors tout simplement pas utile de conserver l'état dans ParentsViewModel et de travailler avec des liaisons lorsque vous pensez à un TableView très complexe, où les cellules elles-mêmes contiennent beaucoup de logique.
Il existe toujours une solution de contournement pour les cas individuels, mais je pense que ce serait beaucoup plus facile si le ViewModel ne serait pas recréé.

Dupliquer la question

Je sais qu'il y a beaucoup de questions sur ce problème, toutes concernant des cas d'utilisation très spécifiques. Ici, je veux parler du problème général, sans aller trop loin dans les solutions personnalisées.

Modifier (ajouter un exemple plus détaillé)

Lorsque vous avez un ParentView à changement d'état, comme une liste provenant d'une base de données, d'une API ou d'un cache (pensez à quelque chose de simple). Via un NavigationLink vous pouvez atteindre une page de détail où vous pouvez modifier les données. En changeant les données, le Pattern réactif/déclaratif nous dirait de mettre également à jour le ListView, ce qui "redessinerait" le NavigationLink, ce qui conduirait alors à une recréation du ViewModel.

Je sais que je pourrais stocker le ViewModel dans le ViewModel de ParentView/ParentView, mais c'est la mauvaise façon de le faire IMO. Et comme les abonnements sont détruits et/ou recréés, il peut y avoir des effets secondaires.

7
KonDeichmann

Enfin, il existe une solution fournie par Apple: @StateObject.

En remplaçant @ObservedObject avec @StateObject tout ce qui est mentionné dans mon message initial fonctionne.

Malheureusement, cela n'est disponible que dans iOS 14+.

Ceci est mon code de Xcode 12 Beta (Publié le 23 juin 2020)

struct ContentView: View {

    @State var title = 0

    var body: some View {
        NavigationView {
            VStack {
                Button("Test") {
                    self.title = Int.random(in: 0...1000)
                }

                TestView1()

                TestView2()
            }
            .navigationTitle("\(self.title)")
        }
    }
}

struct TestView1: View {

    @ObservedObject var model = ViewModel()

    var body: some View {
        VStack {
            Button("Test1: \(self.model.title)") {
                self.model.title += 1
            }
        }
    }
}

class ViewModel: ObservableObject {

    @Published var title = 0
}

struct TestView2: View {

    @StateObject var model = ViewModel()

    var body: some View {
        VStack {
            Button("StateObject: \(self.model.title)") {
                self.model.title += 1
            }
        }
    }
}

Comme vous pouvez le voir, le StateObject conserve sa valeur lors du rafraîchissement de la vue parent, pendant que le ObservedObject est en cours de réinitialisation.

1
KonDeichmann

Je suis d'accord avec vous, je pense que c'est l'un des nombreux problèmes majeurs avec SwiftUI. Voici ce que je me retrouve à faire, aussi dégoûtant soit-il.

struct MyView: View {
  @State var viewModel = MyViewModel()

  var body : some View {
    MyViewImpl(viewModel: viewModel)
  }
}

fileprivate MyViewImpl : View {
  @ObservedObject var viewModel : MyViewModel

  var body : some View {
    ...
  }
}

Vous pouvez soit construire le modèle de vue sur place, soit le transmettre, et cela vous donne une vue qui maintiendra votre ObservableObject tout au long de la reconstruction.

1
Timothy

Existe-t-il un moyen de ne pas recréer le ViewModel à chaque fois?

Oui, conservez l'instance de ViewModel extérieur of SomeView et injectez via le constructeur

struct SomeView: View {
    @ObservedObject var viewModel: SomeViewModel  // << only declaration

Existe-t-il un moyen de répliquer le @State Propertywrapper pour @ObservedObject?

Aucun besoin. @ObservedObject est-a déjà DynamicProperty de la même manière que @State

Pourquoi @State garde-t-il l'état sur le redessiner?

Parce qu'il garde son stockage, c'est à dire. valeur enveloppée, extérieur de la vue. (donc, voir à nouveau le premier ci-dessus)

0
Asperi

Vous devez fournir des PassThroughSubject personnalisés dans votre classe ObservableObject. Regardez ce code:

//
//  Created by Франчук Андрей on 08.05.2020.
//  Copyright © 2020 Франчук Андрей. All rights reserved.
//

import SwiftUI
import Combine


struct TextChanger{
    var textChanged = PassthroughSubject<String,Never>()
    public func changeText(newValue: String){
        textChanged.send(newValue)
    }
}

class ComplexState: ObservableObject{
    var objectWillChange = ObservableObjectPublisher()
    let textChangeListener = TextChanger()
    var text: String = ""
    {
        willSet{
            objectWillChange.send()
            self.textChangeListener.changeText(newValue: newValue)
        }
    }
}

struct CustomState: View {
    @State private var text: String = ""
    let textChangeListener: TextChanger
    init(textChangeListener: TextChanger){
        self.textChangeListener = textChangeListener
        print("did init")
    }
    var body: some View {
        Text(text)
            .onReceive(textChangeListener.textChanged){newValue in
                self.text = newValue
            }
    }
}
struct CustomStateContainer: View {
    //@ObservedObject var state = ComplexState()
    var state = ComplexState()
    var body: some View {
        VStack{
            HStack{
                Text("custom state View: ")
                CustomState(textChangeListener: state.textChangeListener)
            }
            HStack{
                Text("ordinary Text View: ")
                Text(state.text)
            }
            HStack{
                Text("text input: ")
                TextInput().environmentObject(state)
            }
        }
    }
}

struct TextInput: View {
    @EnvironmentObject var state: ComplexState
    var body: some View {
        TextField("input", text: $state.text)
    }
}

struct CustomState_Previews: PreviewProvider {
    static var previews: some View {
        return CustomStateContainer()
    }
}

Tout d'abord, j'utilise TextChanger pour passer la nouvelle valeur de .text À .onReceive(...) dans CustomState View. Notez que onReceive dans ce cas obtient PassthroughSubject, pas le ObservableObjectPublisher. Dans le dernier cas, vous n'aurez que Publisher.Output Dans perform: closure, Pas la NewValue. state.text Dans ce cas aurait une ancienne valeur.

Deuxièmement, regardez la classe ComplexState. J'ai créé une propriété objectWillChange pour que les modifications de texte envoient manuellement une notification aux abonnés. C'est presque la même chose que le wrapper @Published. Mais, lorsque le texte change, il enverra à la fois, et objectWillChange.send() et textChanged.send(newValue). Cela vous permet de choisir exactement View, comment réagir au changement d'état. Si vous voulez un comportement ordinaire, mettez simplement l'état dans le wrapper @ObservedObject Dans CustomStateContainer View. Ensuite, vous aurez toutes les vues recréées et cette section obtiendra également des valeurs mises à jour:

HStack{
     Text("ordinary Text View: ")
     Text(state.text)
}

Si vous ne voulez pas qu'ils soient tous recréés, supprimez simplement @ObservedObject. La vue de texte ordinaire cessera de se mettre à jour, mais CustomState le fera. Sans recréation.

mise à jour: si vous voulez plus de contrôle, vous pouvez décider lors de la modification de la valeur, qui voulez-vous informer de ce changement. Vérifiez le code plus complexe:

//
//
//  Created by Франчук Андрей on 08.05.2020.
//  Copyright © 2020 Франчук Андрей. All rights reserved.
//

import SwiftUI
import Combine


struct TextChanger{
//    var objectWillChange: ObservableObjectPublisher
   // @Published
    var textChanged = PassthroughSubject<String,Never>()
    public func changeText(newValue: String){
        textChanged.send(newValue)
    }
}

class ComplexState: ObservableObject{
    var onlyPassthroughSend = false
    var objectWillChange = ObservableObjectPublisher()
    let textChangeListener = TextChanger()
    var text: String = ""
    {
        willSet{
            if !onlyPassthroughSend{
                objectWillChange.send()
            }
            self.textChangeListener.changeText(newValue: newValue)
        }
    }
}

struct CustomState: View {
    @State private var text: String = ""
    let textChangeListener: TextChanger
    init(textChangeListener: TextChanger){
        self.textChangeListener = textChangeListener
        print("did init")
    }
    var body: some View {
        Text(text)
            .onReceive(textChangeListener.textChanged){newValue in
                self.text = newValue
            }
    }
}
struct CustomStateContainer: View {
    //var state = ComplexState()
    @ObservedObject var state = ComplexState()
    var body: some View {
        VStack{
            HStack{
                Text("custom state View: ")
                CustomState(textChangeListener: state.textChangeListener)
            }
            HStack{
                Text("ordinary Text View: ")
                Text(state.text)
            }
            HStack{
                Text("text input with full state update: ")
                TextInput().environmentObject(state)
            }
            HStack{
                Text("text input with no full state update: ")
                TextInputNoUpdate().environmentObject(state)
            }
        }
    }
}

struct TextInputNoUpdate: View {
    @EnvironmentObject var state: ComplexState
    var body: some View {
        TextField("input", text: Binding(   get: {self.state.text},
                                            set: {newValue in
                                                self.state.onlyPassthroughSend.toggle()
                                                self.state.text = newValue
                                                self.state.onlyPassthroughSend.toggle()
        }
        ))
    }
}

struct TextInput: View {
    @State private var text: String = ""
    @EnvironmentObject var state: ComplexState
    var body: some View {

        TextField("input", text: Binding(
            get: {self.text},
            set: {newValue in
                self.state.text = newValue
               // self.text = newValue
            }
        ))
            .onAppear(){
                self.text = self.state.text
            }.onReceive(state.textChangeListener.textChanged){newValue in
                self.text = newValue
            }
    }
}

struct CustomState_Previews: PreviewProvider {
    static var previews: some View {
        return CustomStateContainer()
    }
}

J'ai créé une liaison manuelle pour arrêter la diffusion de objectWillChange. Mais vous devez toujours obtenir une nouvelle valeur à tous les endroits où vous modifiez cette valeur pour rester synchronisé. C'est pourquoi j'ai aussi modifié TextInput.

C'est ce dont vous aviez besoin?

0
Aspid