Reputation: 3089
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
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
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
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
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
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