Travis
Travis

Reputation: 3089

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)")
            }
        }
    }
}

Upvotes: 2

Views: 1048

Answers (5)

malhal
malhal

Reputation: 30746

Here is my simple workaround for this bug:

    var body: some View {
        VStack {
            List(list) { elem in
                Text(elem.val)
            }
            .id(myVar) // force it to make a new list that includes the new refresh action.
            .refreshable {
                print("myVar: \(myVar)")
            }
        }
    }

Upvotes: -2

wristbands
wristbands

Reputation: 1389

I agree with juliand665. I think this is clearly a bug, as pretty much all other aspects of the view do update with changes to view inputs. While juliand665's workaround did not work for me, I was able to come up with my own version of it that does work!

Basically you just have to handle the refresh state in a StateObject, which the refreshable action uses to know whether or not it should indicate refreshing. And then that same refresh state is used by an onChange modifier to actually perform the refresh work. Here's the full code below:

extension View {
  func refreshableBugFixed(action: @escaping () async -> Void) -> some View {
    modifier(RefreshableBugFixedModifier(action: action))
  }
}

private struct RefreshableBugFixedModifier: ViewModifier {
  let action: () async -> Void

  @StateObject private var refreshState = RefreshState()

  func body(content: Content) -> some View {
    content
      .refreshable {
        refreshState.refreshing = true
        // wait until refreshing becomes false
        await refreshState.$refreshing.values.first { $0 == false }
      }
      .onChange(of: refreshState.refreshing) { nowRefreshing in
        if nowRefreshing {
          Task {
            await action()
            refreshState.refreshing = false
          }
        }
      }
  }
}

private class RefreshState: ObservableObject {
  @Published var refreshing = false
}

Upvotes: 1

juliand665
juliand665

Reputation: 3364

I'm not sure what the others are on about here, this is clearly a bug to me! Other closures like task(id:_:) and friends update as expected. It seems List is not updating its refresh action from the environment after initially receiving one; probably a problem in the glue between SwiftUI and UIKit.

I've found a workaround though, declaring a new modifier that uses @State to internally update the closure:

extension View {
    func actuallyRefreshable(action: @escaping () async -> Void) -> some View {
        modifier(RefreshableWorkaroundModifier(storedAction: action, action: action, id: .init()))
    }
}

private struct RefreshableWorkaroundModifier: ViewModifier {
    @State var storedAction: () async -> Void
    var action: () async -> Void
    var id: UUID
    
    func body(content: Content) -> some View {
        content
            .refreshable {
                await storedAction()
            }
            .onChange(of: id) { _ in
                storedAction = action
            }
    }
}

Upvotes: 2

Cheezzhead
Cheezzhead

Reputation: 336

Roland's answer is correct. Use a binding so that the correct myVar value is used.

As to why: .refreshable, along with other stateful modifiers like .task, .onAppear, .onReceive, etc, operate on a different phase in the SwiftUI View lifecycle. You are correct in assuming that the closure passed to refreshable is stored in the environment and doesn't get updated as the views are recreated. This is intentional. It would make little sense to recreate this closure whenever the view is updated, because updating the view is kind of its intended goal.

You can think of .refreshable (and the other modifiers mentioned above) as similar to the @State and @StateObject property wrappers, in that they are persisted across view layout updates. A @Binding property can also be considered stateful because it is a two-way 'binding' to a state variable from a parent view.

In fact generally speaking, the closures you pass to .refreshable or .task should only read and write to stateful properties (such as viewModels) for this exact reason.

Upvotes: 0

Roland Lariotte
Roland Lariotte

Reputation: 3502

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)") }
    }
  }
}

Upvotes: 1

Related Questions