Skip to content

Instantly share code, notes, and snippets.

@jegnux
Last active February 1, 2024 14:51
Show Gist options
  • Star 38 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save jegnux/c3aee7957f6c372bf31a46c893a6e2a2 to your computer and use it in GitHub Desktop.
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))
}
}
}
@jegnux
Copy link
Author

jegnux commented Apr 15, 2021

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 the if #available(iOS 14, *) I can safely call the original method without causing a recursive loop.

@bennokress
Copy link

Nice solution. One thing you might want to add: Just in line 22 requires import Combine, otherwise it won't build.

@jegnux
Copy link
Author

jegnux commented Apr 21, 2021

@bennokress thanks, it's done :)

@shaps80
Copy link

shaps80 commented Jan 28, 2022

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 👍

@jegnux
Copy link
Author

jegnux commented Feb 4, 2022

@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.

CleanShot 2022-02-04 at 09 37 45@2x

@shaps80
Copy link

shaps80 commented Feb 11, 2022

@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.

@jegnux
Copy link
Author

jegnux commented Feb 11, 2022

@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.

CleanShot 2022-02-11 at 16 58 57@2x

@shaps80
Copy link

shaps80 commented Feb 14, 2022

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 👍

@shaps80
Copy link

shaps80 commented Feb 14, 2022

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 👍

@KoCMoHaBTa
Copy link

KoCMoHaBTa commented Mar 10, 2022

What is the purpose of the onAppear() on line 22?

@KoCMoHaBTa
Copy link

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

@kuanfajardo
Copy link

+1 To what is the purpose of onAppear

@jegnux
Copy link
Author

jegnux commented Apr 25, 2022

@KoCMoHaBTa @kuanfajardo I don't remember well but it was probably a workaround to some bug.

@heecheon92
Copy link

This is such a beautiful solution...... wow......

@subtranix
Copy link

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

@shaps80
Copy link

shaps80 commented Nov 10, 2023

@subtranix my backports library actually has some improvements and is far more battle tested against a lot more projects id suggest checking that out.

https://github.com/shaps80/SwiftUIBackports/blob/main/Sources/SwiftUIBackports/Shared/OnChange/OnChange.swift

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment