Subscribe SwiftUI View to every update of a combine publisher - swiftui

Here's a simplified example of an approach I want to take, but I can't get the simple example to work.
I have a Combine publisher who's subject is a view model State:
struct State {
let a: Bool
let b: Bool
let transition: Transition?
}
The State includes a transition property. This describes the Transition that the State made in order to become the current state.
enum Transition {
case onAChange, onBChange
}
I want to use transition property to drive animations in a View subscribed to the publisher so that different transitions animate in specific ways.
View code
Here's the code for the view. You can see how it tries to use the transition to choose an animation to update with.
struct TestView: View {
let model: TestViewModel
#State private var state: TestViewModel.State
private var cancel: AnyCancellable?
init(model: TestViewModel) {
self.model = model
self._state = State(initialValue: model.state.value)
self.cancel = model.state.sink(receiveValue: updateState(state:))
}
var body: some View {
VStack(spacing: 20) {
Text("AAAAAAA").scaleEffect(state.a ? 2 : 1)
Text("BBBBBBB").scaleEffect(state.b ? 2 : 1)
}
.onTapGesture {
model.invert()
}
}
private func updateState(state: TestViewModel.State) {
withAnimation(animation(for: state.transition)) {
self.state = state
}
}
private func animation(for transition: TestViewModel.Transition?) -> Animation? {
guard let transition = transition else { return nil }
switch transition {
case .onAChange: return .easeInOut(duration: 1)
case .onBChange: return .easeInOut(duration: 2)
}
}
}
struct TestView_Previews: PreviewProvider {
static var previews: some View {
TestView(model: TestViewModel())
}
}
Model code
final class TestViewModel: ObservableObject {
var state = CurrentValueSubject<State, Never>(State(a: false, b: false, transition: nil))
struct State {
let a: Bool
let b: Bool
let transition: Transition?
}
enum Transition {
case onAChange, onBChange
}
func invert() {
let oldState = state.value
setState(newState: .init(a: !oldState.a, b: oldState.b, transition: .onAChange))
setState(newState: .init(a: !oldState.a, b: !oldState.b, transition: .onBChange))
}
private func setState(newState: State) {
state.value = newState
}
}
You can see in the model code that when invert() is called, two state changes occur. The model first toggles a using the .onAChange transition, and then toggles b using the .onBChange transition.
What should happen
What should happen when this is run is that each time the view is clicked, the text "AAAAAAA" and "BBBBBBB" should toggle size. However, the "AAAAAAA" text should change quickly (1 second) and the "BBBBBBB" text should change slowly (2 seconds).
What actually happens
However, when I run this and click on the view, the view doesn't update at all.
I can see from the debugger that onTapGesture { … } is called and invert() is being called on the model. Also updateState(state:) is also being called. However, TestView is not changing on screen, and body is not invoked again.
Other things I tried
Using a callback
Instead of using a publisher to send the event to the view, I've tried a callback function in the model set to the view's updateState(state:) function. I assigned to this in the init of the view with model.handleUpdate = self.update(state:). Again, this did not work. The function invert() and update(state:) were called, as expected, but the view didn't actually change.
Using #ObservedObject
I change the model to be ObservableObject with its state being #Published. I set up the view to have an #ObservedOject for the model. With this, the view does update, but it updates both pieces of text using the same animation, which I don't want. It seems that the two state updates are squashed and it only sees the last one, and uses the transition from that.
Something that did work – sort of
Finally, I tried to directly copy the model's invert() function in to the view's onTapGesture handler, so that the view updates its own state directly. This did work! Which is something, but I don't want to put all by model update logic in my view.
Question
How can I have a SwiftUI view subscribe to all states that a model sends through its publisher so that it can use a transition property in the state to control the animation used for that state change?

The way you subscribe a view to the publisher is by using .onRecieve(_:perform:), so instead of saving a cancellable inside init, do this:
var body: some View {
VStack(spacing: 20) {
Text("AAAAAAA").scaleEffect(state.a ? 2 : 1)
Text("BBBBBBB").scaleEffect(state.b ? 2 : 1)
}
.onTapGesture {
model.invert()
}
.onReceive(model.state, perform: updateState(state:)) // <- here
}

Related

How to reset a cancelled subscription bound to SwiftUI

In the following example code, a SwiftUI form holds an Observable object that holds a trivial pipeline that passes a string through to a #Published value. That object is being fed by the top line of the SwiftUI form, and the output is being displayed on the second line.
The value in the text field in the first row gets propagated to the output line in the second row, whenever we hit the "Send" button, unless we hit the "End" button, which cancels the subscription, as we'd expect.
import SwiftUI
import Combine
class ResetablePipeline: ObservableObject {
#Published var output = ""
var input = PassthroughSubject<String, Never>()
init(output: String = "") {
self.output = output
self.input
.assign(to: &$output)
}
func reset()
{
// What has to go here to revive a completed pipeline?
self.input
.assign(to: &$output)
}
}
struct ResetTest: View {
#StateObject var pipeline = ResetablePipeline()
#State private var str = "Hello"
var body: some View {
Form {
HStack {
TextField(text: $str, label: { Text("String to Send")})
Button {
pipeline.input.send(str)
} label: {
Text("Send")
}.buttonStyle(.bordered)
Button {
pipeline.input.send(completion: .finished)
} label: {
Text("End")
}.buttonStyle(.bordered)
}
Text("Output: \(pipeline.output)")
Button {
pipeline.reset()
} label: {
Text("Reset")
}
}
}
}
struct ResetTest_Previews: PreviewProvider {
static var previews: some View {
ResetTest()
}
}
My understanding is that hitting "End" and completing/cancelling the subscription will delete all the Combine nodes that were set up in the ResetablePipeline.init function (currently only the assign operator).
But if we wanted to reset that connection, how would we do that (without creating a new ResetablePipeline object). What would you have to do in reset() to reconnect the plumbing in the ResetablePipeline object, so that the Send button would work again? Why does the existing code not work?
It is part of the fundamental nature of a Publisher that once the Publisher has finished, or has emitted an error, that the publisher will never emit another value.
This is described in Reactive X in the Observable Contract
The fundamental reason for this is that when the pipeline finishes, the stages in the pipeline are free to release any resources they may have obtained. For example, if a collect operator has set aside memory for its connected items, it can release that memory once the pipeline finishes.
In short, there is no way to do what you want to do. You cannot restart a pipeline that has finished, though you can construct a new one.
Well I'll be. If I simply add input = PassthroughSubject<String, Never>() to the start of reset() (ie replace the original cancelled head-publisher with a fresh one), it seems to do the trick.
Now, I'm not entirely sure if this code is not leaking something since I don't know exactly what assign(to:) does with the old subscription, but assuming that it's sensible, this might be OK.
Can anyone see anything wrong with this approach?

How to conditionally execute code on onAppear method

I have a swiftUi view depending on a class data.
Before displaying the data, I have to compute it in .onAppear method.
I would like to make this heavy computation only when my observed object changes.
The problem is that .onAppear is called every time I open the view, but the object value does not change very often.
Is it possible to conditionally execute the compute function, only when observed data has effectively been modified ?
import SwiftUI
struct test2: View {
#StateObject var person = Person()
#State private var computedValue = 0
var body: some View {
List {
Text("age = \(person.age)")
Text("computedValue = \(computedValue)")
}
.onAppear {
computedValue = compute(person.age) /// Executed much too often :(
}
}
func compute(_ age: Int) -> Int {
return age * 2 /// In real life, heavy computing
}
}
class Person: ObservableObject {
var age: Int = 0
}
Thanks for advice :)
It would probably be a little less code in the view model, but to do all of the calculations in the view, you will need a few changes. First, your class Person has no #Published variables, so it will never call for a state change. I have fixed that.
Second, now that your state will update, you can add an .onReceive() to the view to keep track of when age updates.
Third, and extremely important, to keep from blocking the main thread with the "heavy computing", you should implement Async Await. As a result, even though I sleep the thread for 3 seconds, the UI is still fluid.
struct test2: View {
#StateObject var person = Person()
#State private var computedValue = 0
var body: some View {
List {
Text("age = \(person.age)")
Text("computedValue = \(computedValue)")
Button {
person.age = Int.random(in: 1...80)
} label: {
Text("Randomly Change Age")
}
}
// This will do your initial setup
.onAppear {
Task {
computedValue = await compute(person.age) /// Executed much too often :(
}
}
// This will keep it current
.onReceive(person.objectWillChange) { _ in
Task {
computedValue = await compute(person.age) /// Executed much too often :(
}
}
}
func compute(_ age: Int) async -> Int {
//This is just to simulate heavy work.
do {
try await Task.sleep(nanoseconds: UInt64(3.0 * Double(NSEC_PER_SEC)))
} catch {
//handle error
}
return age * 2 /// In real life, heavy computing
}
}
class Person: ObservableObject {
#Published var age: Int = 0
}
A possible solution is that create a EnvironmentObject with a Bool Value, Change The value of that variable when there are Change in your object.
So onappear just check if environmentobject value is true or false and it will execute you function.
Hope You Found This Useful.
task(id:priority:_:) is the solution for that.
"A view that runs the specified action asynchronously when the view appears, or restarts the task with the id value changes."
Set the id param to the data you want to monitor for changes.

How do I track down the dependency that causes a refresh?

I have a fairly complex document type to work with. It is basically a bundle containing a set of independent documents of the same type, with various pieces of metadata about the documents. The data structure that represents the bundle is an array of structs, similar to this (there are several more fields, but these are representative):
struct DocumentData: Equatable, Identifiable, Hashable {
let id = UUID()
var docData: DocumentDataClass
var docName: String
var docFileWrapper: FileWrapper?
func hash(into hasher: inout Hasher) {
id.hash(into: &hasher)
}
static func ==(lhs: KeyboardLayoutData, rhs: KeyboardLayoutData) -> Bool {
return lhs.id == rhs.id
}
}
The window for the bundle is a master-detail, with a list on the left and, when one is selected, there is an edit pane for the document on the right. The FileWrapper is used to keep track of which files need to be written for saving, so it gets initialised on reading the relevant file, and reset when an undoable change is made. That is largely the only way that the DocumentData structure gets changed (ignoring explicit things like changing the name).
I've reached a point where a lot of things are working, but I'm stuck on one. There's a view inside the edit pane, several levels deep, and when I double-click it, I want a sheet to appear. It does so, but then disappears by itself.
Searching for ways to work this out, I discovered by using print(Self._printChanges()) at various points that the edit pane was being refreshed after showing the sheet, which meant that the parent disappeared. What I found was that the dependency that changed was the DocumentData instance. But, I then added a print of the DocumentData instance before the _printChanges call, and it is identical. I have also put in didSet for each field of DocumentData to print when they get set, and nothing gets printed, so I'm not sure where the change is happening.
So the question comes down to how I can work out what is actually driving the refresh, since what is claimed to be different is identical in every field.
There are some other weird things happening, such as dragging and dropping text into the view causing the whole top-level document array of DocumentData items to change before the drop gets processed and the data structures get updated, so there are things I am not understanding as clearly as I might like. Any guidance is much appreciated.
ADDED:
The view that triggers the sheet is fairly straightforward, especially compared to its enclosing view, which is where most of the interface code is. This is a slightly simplified version of it:
struct MyView: View, DropDelegate {
#EnvironmentObject var keyboardStatus: KeyboardStatus
#Environment(\.displayFont) var displayFont
#Environment(\.undoManager) var undoManager
var keyCode: Int
#State var modifiers: NSEvent.ModifierFlags = []
#State private var dragHighlight = false
#State private var activeSheet: ActiveSheet?
#State private var editPopoverIsPresented = false
// State variables for double click and drop handling
...
static let dropTypes = [UTType.utf8PlainText]
var body: some View {
ZStack {
BackgroundView(...)
Text(...)
}
.onAppear {
modifiers = keyboardStatus.currentModifiers
}
.focusable(false)
.allowsHitTesting(true)
.contentShape(geometry.contentPath)
.onHover { entered in
// updates an inspector view
}
.onTapGesture(count: 2) {
interactionType = .doubleClick
activeSheet = .doubleClick
}
.onTapGesture(count: 1) {
handleItemClick()
}
.sheet(item: $activeSheet, onDismiss: handleSheetReturn) { item in
switch item {
case .doubleClick:
DoubleClickItem(...) ) {
activeSheet = nil
}
case .drop:
DropItem(...) {
activeSheet = nil
}
}
}
.popover(isPresented: $editPopoverIsPresented) {
EditPopup(...)
}
.onDrop(of: KeyCap.dropTypes, delegate: self)
.contextMenu {
ItemContextMenu(...)
}
}
func handleItemClick() {
NotificationCenter.default.post(name: .itemClick, object: nil, userInfo: [...])
}
func handleEvent(event: KeyEvent) {
if event.eventKind == .dropText {
interactionType = .drop
activeSheet = .drop
}
else if event.eventKind == .replaceText {
...
handleItemDoubleClick()
}
}
func handleSheetReturn() {
switch interactionType {
case .doubleClick:
handleItemDoubleClick()
case .drop:
handleItemDrop()
case .none:
break
}
}
func handleItemDoubleClick() {
switch itemAction {
case .state1:
...
case .state2:
...
case .none:
// User cancelled
break
}
interactionType = nil
}
func handleItemDrop() {
switch itemDropAction {
case .action1:
...
case .action2:
...
case .none:
// User cancelled
break
}
interactionType = nil
}
// Drop delegate
func dropEntered(info: DropInfo) {
dragHighlight = true
}
func dropExited(info: DropInfo) {
dragHighlight = false
}
func performDrop(info: DropInfo) -> Bool {
if let item = info.itemProviders(for: MyView.dropTypes).first {
item.loadItem(forTypeIdentifier: UTType.utf8PlainText.identifier, options: nil) { (textData, error) in
if let textData = String(data: textData as! Data, encoding: .utf8) {
let event = ...
handleEvent(event: event)
}
}
return true
}
return false
}
}
Further edit:
I ended up rewiring the code so that the sheet belongs to the higher level view, which makes everything work without solving the question. I still don't understand why I get a notification that a dependency has changed when it is identical to what it was before, and none of the struct's didSet blocks are called.
Try removing the class from the DocumentData. The use of objects in SwiftUI can cause these kind of bugs since it’s all designed for value types.
Try using ReferenceFileDocument to work with your model object instead of FileDocument which is designed for a model of value types.
Try using sheet(item:onDismiss:content:) for editing. I've seen people have the problem you describe when they try to hack the boolean sheet to work with editing an item.

How to prod a SwiftUI view to update when a model class sub-property changes?

I've created a trivial project to try to understand this better. Code below.
I have a source of data (DataSource) which contains a #Published array of MyObject items. MyObject contains a single string. Pushing a button on the UI causes one of the MyObject instances to update immediately, plus sets off a timer to update a second one a few seconds later.
If MyObject is a struct, everything works as I imagine it should. But if MyObject is a class, then the refresh doesn't fire.
My expectation is that changing a struct's value causes an altered instance to be placed in the array, setting off the chain of updates. However, if MyObject is a class then changing the string within a reference type leaves the same instance in the array. Array doesn't realise there has been a change so doesn't mention this to my DataSource. No UI update happens.
So the question is – what needs to be done to cause the UI to update when the MyObject class's property changes? I've attempted to make MyObject an ObservableObject and throw in some didchange.send() instructions but all without success (I believe these are redundant now in any case).
Could anyone tell me if this is possible, and how the code below should be altered to enable this? And if anyone is tempted to ask why I don't just use a struct, the reason is because in my actual project I have already tried doing this. However I am using collections of data types which modify themselves in closures (parallel processing of each item in the collection) and other hoops to jump through. I tried re-writing them as structs but ran in to so many challenges.
import Foundation
import SwiftUI
struct ContentView: View
{
#ObservedObject var source = DataSource()
var body: some View
{
VStack
{
ForEach(0..<5)
{i in
HelloView(displayedString: self.source.results[i].label)
}
Button(action: {self.source.change()})
{
Text("Change me")
}
}
}
}
struct HelloView: View
{
var displayedString: String
var body: some View
{
Text("\(displayedString)")
}
}
class MyObject // Works if declared as a Struct
{
init(label: String)
{
self.label = label
}
var label: String
}
class DataSource: ObservableObject
{
#Published var results = [MyObject](repeating: MyObject(label: "test"), count: 5)
func change()
{
print("I've changed")
results[3].label = "sooner"
_ = Timer.scheduledTimer(withTimeInterval: 2, repeats: false, block: {_ in self.results[1].label = "Or later"})
}
}
struct ContentView_Previews: PreviewProvider
{
static var previews: some View
{
ContentView()
}
}
When MyObject is a class type the results contains references, so when you change property of any instance inside results the reference of that instance is not changed, so results is not changed, so nothing published and UI is not updated.
In such case the solution is to force publish explicitly when you perform any change of internal model
class DataSource: ObservableObject
{
#Published var results = [MyObject](repeating: MyObject(label: "test"), count: 5)
func change()
{
print("I've changed")
results[3].label = "sooner"
self.objectWillChange.send() // << here !!
_ = Timer.scheduledTimer(withTimeInterval: 2, repeats: false) {[weak self] _ in
self?.results[1].label = "Or later"
self?.objectWillChange.send() // << here !!
}
}
}

AVSpeechSynthesizerDelegate implementation in SwiftUI

Can anyone share how we can implement AVSpeechSynthesizerDelegate in SwiftUI.
how we can listen to delegate callbacks methods in SwiftUI app.
Thanks
One solution would be to define a class which conforms to ObservableObject. The idea would be to use an #Published property to enable SwiftUI to make updates to your UI. Here's an example of a simple way to keep track of the state of an AVSpeechSynthesizer (I'm unsure of your actual use case):
final class Speaker: NSObject, ObservableObject {
#Published var state: State = .inactive
enum State: String {
case inactive, speaking, paused
}
override init() {
super.init()
synth.delegate = self
}
func speak(words: String) {
synth.speak(.init(string: words))
}
private let synth: AVSpeechSynthesizer = .init()
}
Then, make this class conform to AVSpeechSynthesizerDelegate like so:
extension Speaker: AVSpeechSynthesizerDelegate {
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) {
self.state = .speaking
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didPause utterance: AVSpeechUtterance) {
self.state = .paused
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
self.state = .inactive
}
// etc...
}
Here, I've simply used the delegate callbacks to update a single #Published property, but you could update however you like here depending on your use case. The main point to bear in mind with ObservableObjects is using the #Published property wrapper for any properties you wish to drive UI updates upon a change in value. Here's an example view:
struct MyView: View {
#ObservedObject var speaker: Speaker
var body: some View {
// 1
Text("State = \(speaker.state.rawValue)")
.onReceive(speaker.$state) { state in
// 2
}
}
}
Note how there's two ways to use #Published properties in SwiftUI Views. 1: Simply read the value. SwiftUI will update your view upon a value change. 2: Access the #Published property's publisher with the $ prefix. Using Views onReceive method, you can execute code whenever the publisher emits a value.