Prevent updateUIView() from being called in UIRepresentableView? - swiftui

I think I need to change a boolean in a UIRepresentableView before removing it from the ContentView but can't find a way, since I can't mutate the struct properties.
Why do I need that? Because I need to prevent updateUIView() from being called one last time before the view being released.
So probably this is an XY problem... anyway, I'm lost.
I have a model like this:
class PlayerModel: NSObject, ObservableObject {
#Published var lottie: [LottieView]
Where LottieView is this:
struct LottieView: UIViewRepresentable {
var name: String
var loopMode: LottieLoopMode = .playOnce
func makeUIView(context: UIViewRepresentableContext<LottieView>) -> UIView {
let view = UIView()
return view
}
func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<LottieView>) {
uiView.subviews.forEach({ sub in
sub.removeFromSuperview()
})
let animationView = AnimationView()
animationView.translatesAutoresizingMaskIntoConstraints = false
uiView.addSubview(animationView)
NSLayoutConstraint.activate([
animationView.widthAnchor.constraint(equalTo: uiView.widthAnchor),
animationView.heightAnchor.constraint(equalTo: uiView.heightAnchor)
])
animationView.animation = Animation.named(name, animationCache: LRUAnimationCache.sharedCache)
animationView.contentMode = .scaleAspectFit
animationView.loopMode = loopMode
animationView.play()
}
}
In my ContentView I only display a LottieView if there's a recent one in the published array:
struct ContentView: View {
#ObservedObject var model: PlayerModel
var body: some View {
NavigationView {
VStack {
// ...
if let lottie = model.lottie.last {
lottie
}
// ...
}
}
}
}
Now this works ok, but when in the model I remove the last LottieView from the published array, the updateUIView() func is called one last time by SwiftUI, resulting in the animation being recreated again just before the LottieView is removed from the view, resulting in a visible flicker.
So I thought I'd add a boolean that I could update just before removing the last LottieView from the published array, and do an if/else in the updateUIView(), but... is this even possible? I can't find a way.
Ideally I would just have the animation view created in makeUIView() and nothing done in updateUIView() but this is not possible, I need to remove the subviews first, otherwise the previous shown animation is not replaced by the new one.

While I would still love to have someone give me a proper solution to my issue, or tell me an alternative way of doing things, I found a workaround.
In the model, I was previously emptying the published array by doing
lottie = []
But if instead we do this:
lottie.removeAll()
We see no flicker anymore (however updateView() is still called!!).

Related

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.

SwiftUI - Navigate to view after retrieving data

So I'm retrieving data from FireStore. I'm retrieving the data successfully. When I tap my search button the first time the data is being downloaded and the new view is pushed. As a result, I get a blank view. But when I go back, hit search again, sure enough I can see my data being presented.
How can I make sure I first have the data I'm searching for THEN navigate to the new view? I've used #State variables etc. But nothing seems to be working. I am using the MVVM approach.
My ViewModel:
class SearchPostsViewModel: ObservableObject {
var post: [PostModel] = []
#State var searchCompleted: Bool = false
func searchPosts(completed: #escaping() -> Void, onError: #escaping(_ errorMessage: String) -> Void) {
isLoading = true
API.Post.searchHousesForSale(propertyStatus: propertyStatus, propertyType: propertyType, location: location, noOfBathrooms: noOfBathroomsValue, noOfBedrooms: noOfBedroomsValue, price: Int(price!)) { (post) in
self.post = post
print(self.post.count)
self.isLoading = false
self.searchCompleted.toggle()
}
}
}
The code that does work, but with the bug:
NavigationLink(destination: FilterSearchResults(searchViewModel: self.searchPostsViewModel)
.onAppear(perform: {
DispatchQueue.main.async {
self.createUserRequest()
}
})
)
{
Text("Search").modifier(UploadButtonModifier())
}
Try with the following modified view model
class SearchPostsViewModel: ObservableObject {
#Published var post: [PostModel] = [] // << make both published
#Published var searchCompleted: Bool = false
func searchPosts(completed: #escaping() -> Void, onError: #escaping(_ errorMessage: String) -> Void) {
isLoading = true
API.Post.searchHousesForSale(propertyStatus: propertyStatus, propertyType: propertyType, location: location, noOfBathrooms: noOfBathroomsValue, noOfBedrooms: noOfBedroomsValue, price: Int(price!)) { (post) in
DispatchQueue.main.async {
self.post = post // << update on main queue
print(self.post.count)
self.isLoading = false
self.searchCompleted.toggle()
}
}
}
}
You should look at the Apple documentation for #State and ObservableObject
https://developer.apple.com/documentation/combine/observableobject
https://developer.apple.com/documentation/swiftui/state
Your issue is with using an #State in a non-UI class/View.
It might help if you start with the Apple SwiftUI tutorials. So you understand the differences in with the wrappers and learn how it all connects.
https://developer.apple.com/tutorials/swiftui
Also, when you post questions make sure your code can be copied and pasted onto Xcode as-is so people can test it. You will get better feedback if other developers can see what is actually happening. As you progress it won't be as easy to see issues.

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.

SwiftUI UIViewRepresentable UITextView Binding

Multiline text input is currently not natively supported in SwiftUI (hopefully this feature is added soon!) so I've been trying to use the combine framework to implement a UITextView from UIKit which does support multiline input, however i've been having mixed results.
This is the code i've created to make the Text view:
struct MultilineTextView: UIViewRepresentable {
#Binding var text: String
func makeUIView(context: Context) -> UITextView {
let view = UITextView()
view.isScrollEnabled = true
view.isEditable = true
view.isUserInteractionEnabled = true
view.backgroundColor = UIColor.white
view.textColor = UIColor.black
view.font = UIFont.systemFont(ofSize: 17)
view.delegate = context.coordinator
return view
}
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.text = text
}
func frame(numLines: CGFloat) -> some View {
let height = UIFont.systemFont(ofSize: 17).lineHeight * numLines
return self.frame(height: height)
}
func makeCoordinator() -> MultilineTextView.Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UITextViewDelegate {
var parent: MultilineTextView
init(_ parent: MultilineTextView) {
self.parent = parent
}
func textViewDidChange(_ textView: UITextView) {
parent.text = textView.text
}
}
}
I've then implemented it in a swiftUI view like:
MultilineTextView(text: title ? $currentItem.titleEnglish : $currentItem.pairArray[currentPair].english)//.frame(numLines: 4)
And bound it to a state variable:
#State var currentItem:Item
It sort of works. However, the state var currentItem:Item contains an array of strings which I'm then cycling through using buttons which update the string array based on what has been inputted into MultilineTextView. This is where i'm encountering a problem where the MultilineTextView seems to bind to only the first string item in the array, and then it won't change. When I use swiftUI's native TextField view this functionality works fine and I can cycle through the string array and update it by inputting text into the TextField.
I think I must be missing something in the MultilineTextView struct to allow this functionality. Any pointers are gratefully received.
Update: Added model structs
struct Item: Identifiable, Codable {
let id = UUID()
var completed = false
var pairArray:[TextPair]
}
struct TextPair: Identifiable, Codable {
let id = UUID()
var textOne:String
var textTwo:String
}
Edit:
So I've done some more digging and I've found what I think is the problem. When the textViewDidChange of the UITextView is triggered, it does send the updated text which I can see in the console. The strange thing is that the updateUIView function then also gets triggered and it updates the UITextView's text with what was in the binding var before the update was sent via textViewDidChange. The result is that the UITextview just refuses to change when you type into it. The strange thing is that it works for the first String in the array, but when the item is changed it won't work anymore.
It appears that SwiftUI creates two variants of UIViewRepresentable, for each binding, but does not switch them when state, ie title is switched... probably due to defect, worth submitting to Apple.
I've found worked workaround (tested with Xcode 11.2 / iOS 13.2), use instead explicitly different views as below
if title {
MultilineTextView(text: $currentItem.titleEnglish)
} else {
MultilineTextView(text: $currentItem.pairArray[currentPair].textOne)
}
So I figured out the problem in the end, the reason why it wasn't updating was because I was passing in a string which was located with TWO state variables. You can see that in the following line, currentItem is one state variable, but currentPair is another state variable that provides an index number to locate a string. The latter was not being updated because it wasn't also being passed into the multiline text view via a binding.
MultilineTextView(text: title ? $currentItem.titleEnglish : $currentItem.pairArray[currentPair].english)
I thought initially that passing in one would be fine and the parent view would handle the other one but this turns out not to be the case. I solved my problem by making two binding variables so I could locate the string that I wanted in a dynamic way. Sounds stupid now but I couldn't see it at the time.