-
-
Save jegnux/c3aee7957f6c372bf31a46c893a6e2a2 to your computer and use it in GitHub Desktop.
import SwiftUI | |
import Combine | |
public struct ChangeObserver<V: Equatable>: ViewModifier { | |
public init(newValue: V, action: @escaping (V) -> Void) { | |
self.newValue = newValue | |
self.newAction = action | |
} | |
private typealias Action = (V) -> Void | |
private let newValue: V | |
private let newAction: Action | |
@State private var state: (V, Action)? | |
public func body(content: Content) -> some View { | |
if #available(iOS 14, *) { | |
assertionFailure("Please don't use this ViewModifer directly and use the `onChange(of:perform:)` modifier instead.") | |
} | |
return content | |
.onAppear() | |
.onReceive(Just(newValue)) { newValue in | |
if let (currentValue, action) = state, newValue != currentValue { | |
action(newValue) | |
} | |
state = (newValue, newAction) | |
} | |
} | |
} | |
extension View { | |
@_disfavoredOverload | |
@ViewBuilder public func onChange<V>(of value: V, perform action: @escaping (V) -> Void) -> some View where V: Equatable { | |
if #available(iOS 14, *) { | |
onChange(of: value, perform: action) | |
} else { | |
modifier(ChangeObserver(newValue: value, action: action)) | |
} | |
} | |
} |
Nice solution. One thing you might want to add: Just
in line 22 requires import Combine
, otherwise it won't build.
@bennokress thanks, it's done :)
I have my own implementation of this (similar but different) but one issue I found (and I imagine its the same here) is that the behaviour is ever so slightly different.
onReceive
is called essentially after the change, whereas onChange
is called before, allowing the consumer to compare the previous value with the newValue
if required.
I never found a way to implement it this way and mostly is a non-issue. Just mentioning here for others in case its unclear that the behaviour is not identical to that of onChange
and this may be important in some cases 👍
@shaps80 Apple's implementation of onChange
achieve this by allowing to capture the "oldValue" as part of the closure as described in the documentation (see below). My implementation should work the same.
@jegnux That's true but I tested this and both yours and my own solution don't appear to work this way. Essentially it seems the @State properties have already changed in our implementations. Not sure how Apple's achieving this tbh. I'd love it if you can prove otherwise but in my own testing it didn't behave that way.
@shaps80 I just tried it in a sample project and it seems to work as I described it.
If you're able to setup a sample project showcasing your issue, I'd be happy to help you.
Interesting, I'll definitely re-test then as I'm 99% (was 100% hahaha) certain this didn't behave this way for me. I'd be super happy to be wrong here mind you 👍
Ok I can see my issue. I thought it was a shadowed property but actually that wasn't it.
Essentially I've also ported the .task(id:)
modifier on top of this. However it was there that the task id
change was triggering correctly, however the value appeared to always be the previous value.
I've now identified its because in my TaskModifier
implementation, I had passed my id
in as a @State
property which was a mistake. Removing that property wrapper/annotation now fixed my issue.
So the onChange
implementation (both yours and mine) were not at all at fault. My mistake 👍
What is the purpose of the onAppear()
on line 22?
Also i'm getting [SwiftUI] Modifying state during view update, this will cause undefined behavior.
on line 27, which is fixable by adding .receive(on: DispatchQueue.main)
operator to the Just
on line 13
-> .onReceive(Just(newValue).receive(on: DispatchQueue.main)) { newValue in
+1 To what is the purpose of onAppear
@KoCMoHaBTa @kuanfajardo I don't remember well but it was probably a workaround to some bug.
This is such a beautiful solution...... wow......
Great solution! Saved me. Just one question: What's the idea of putting action
into state
? Seems, the action never changes within the struct lifecycle
@subtranix my backports library actually has some improvements and is far more battle tested against a lot more projects id suggest checking that out.
This version of
onChange(of:perform:)
is a full iOS 13 compatible drop-in replacement of iOS 14's eponym modifier.Thanks to
@_disfavoredOverload
this method will be used in favor of native modifier only where the iOS 13 compatibility is required. This is why in theif #available(iOS 14, *)
I can safely call the original method without causing a recursive loop.