swiftuiswiftui-listswiftui-environment

Bug in SwiftUI? iOS 15. List refresh action is executed on an old instance of View -- how to work around?


I'm using the refreshable modifier on List https://developer.apple.com/documentation/SwiftUI/View/refreshable(action:)

The List is contained in a view (TestChildView) that has a parameter. When the parameter changes, TestChildView is reinstantiated with the new value. The list has a refreshable action. However, when pulling down to refresh the list, the refresh action is run against the original view instance, so it doesn't see the current value of the parameter.

To reproduce with the following code: If you click the increment button a few times, you can see the updated value propagating to the list item labels. However, if you pull down the list to refresh, it prints the original value of the parameter.

I assume this is happening because of how refreshable works .. it sets the refresh environment value, and I guess it doesn't get updated as new instances of the view are created.

It seems like a bug, but I'm looking for a way to work around -- how can the refreshable action see the current variable/state values?

import SwiftUI

struct TestParentView: View {
    @State var myVar = 0

    var body: some View {
        VStack {
            Text("increment")
                .onTapGesture {
                    myVar += 1
                }
            TestChildView(myVar: myVar)
        }
    }
}


struct TestChildView: View {
    let myVar: Int
    
    struct Item: Identifiable {
        var id: String {
            return val
        }
        
        let val: String
    }

    var list: [Item] {
        return [Item(val: "a \(myVar)"), Item(val: "b \(myVar)"), Item(val: "c \(myVar)")]
    }
    
    var body: some View {
        VStack {
            List(list) { elem in
                Text(elem.val)
            }.refreshable {
                print("myVar: \(myVar)")
            }
        }
    }
}


Solution

  • The value of myVar in TestChildView is not updated because it has to be a @Binding. Otherwise, a new view is recreated.

    If you pass the value @State var myVar from TestParentView to a @Binding var myVar to TestChildView, you will have the value being updated and the view kept alive the time of the parent view.

    You will then notice that the printed value in your console is the refreshed one of the TestChildView.

    Here is the updated code (See comments on the updated part).

    import SwiftUI
    
    struct TestParentView: View {
    
      @State var myVar = 0
    
      var body: some View {
        VStack {
          Text("increment")
            .onTapGesture { myVar += 1 }
    
          TestChildView(myVar: $myVar) // Add `$` to pass the updated value.
        }
      }
    }
    
    
    struct TestChildView: View {
    
      @Binding var myVar: Int // Create the value to be `@Binding`.
    
      struct Item: Identifiable {
        var id: String { return val }
        let val: String
      }
    
      var list: [Item] {
        return [Item(val: "a \(myVar)"), Item(val: "b \(myVar)"), Item(val: "c \(myVar)")]
      }
    
      var body: some View {
        VStack {
          List(list) { elem in
            Text(elem.val)
          }
          .refreshable { print("myVar: \(myVar)") }
        }
      }
    }