#EnvironmentObject not updating view when updating from Realm - swift

Edited to remove unneccesary code...
I'm trying to build a view in my app which will display a schedule (for practicing musical instruments). The schedule can have multiple sessions, and each session multiple slots. I am retrieving a 2d array of these slots from Realm and then using a foreach inside another foreach to display each session with its contents. I'm using #EnvironmentObject to access Realm from each view and realm assembles a new [[Slot]] each time any information is changed (which it seems to be doing correctly).
The issue I'm having is that, although it is refreshing the sessions when I add/remove them, it is not updating the contents of each session. Realm is correctly working correctly, but the sub view is not being updated. If I exit and re-enter the ScheduleView it will show correctly again. I have tried various things to get it updating correctly, including changing an #State variable in each view after a delay, but nothing so far has worked and I'm starting to pull my hair out!
struct SessionRow: View {
#State var sessionNumber: Int
#State var session: [Slot]
var delete: () -> Void
var addSlot: () -> Void
#State var refreshToggle = false
var body: some View {
VStack{
HStack{
Text("Session \(sessionNumber + 1)")
Button {
self.delete()
} label: {
Label("", systemImage: "trash")
}
.buttonStyle(PlainButtonStyle())
Button {
self.addSlot()
} label: {
Label("", systemImage: "plus")
}
.buttonStyle(PlainButtonStyle())
}
ForEach(0..<self.session.count, id: \.self) { slotNumber in
SlotRow(slot: self.session[slotNumber], ownPosition: [self.sessionNumber, slotNumber])
}
.onDelete { indexSet in
//
}
}
.onAppear{
print("Session Number: \(sessionNumber) loaded.")
}
}
}
func addSlot(sessionNumber: Int){
DispatchQueue.main.async {
self.realmManager.addSlot(sessionNumber: sessionNumber)
self.refreshToggle.toggle()
schedule = realmManager.schedule
}
}
func deleteSession(at offsets: IndexSet){
DispatchQueue.main.async {
self.realmManager.deleteSession(sessionNumber: Int(offsets.first ?? 0))
schedule = realmManager.schedule
}
}
}
struct SlotRow: View {
//#EnvironmentObject var realmManager: RealmManager
#State var slot: Slot
//#Binding var isPresented: Bool
//#Binding var slotPosition: [Int]
#State var ownPosition: [Int]
// Add position/session to allow editing?
var body: some View {
VStack{
HStack{
Text("Own Position: [\(ownPosition[0]),\(ownPosition[1])]")
}
}
.padding()
.frame(height: 80.0)
.onAppear{
print("Slot \(ownPosition) loaded.")
}
}
func deleteIntervalFromSlot() {
//realmManager.updateSlot(session: ownPosition[0], position: ownPosition[1], interval: nil)
}
}
As you can see I've tried all sorts of hacks to get it to update, and loads of this code is unnecessary and will be removed once I have a solution. I thought I would leave it here to show the sort of things which have been tried.

I've found the solution, and apologies for the outrageous amount of detail above. I will go back and tidy the above when I get some time.
The issue is that I defined when passing the [Slot] to the session row the variable receiving it was #State and it should just be a var.
struct SessionRow: View {
#State var sessionNumber: Int
#State var session: [Slot]
Change it to
struct SessionRow: View {
var sessionNumber: Int
var session: [Slot]
and it works just fine.
A silly error from someone who is new to SwiftUI!

Related

SwiftUI macOS NavigationView - onChange(of: Bool) action tried to update multiple times per frame

I'm seeing onChange(of: Bool) action tried to update multiple times per frame warnings when clicking on NavigationLinks in the sidebar for a SwiftUI macOS App.
Here's what I currently have:
import SwiftUI
#main
struct BazbarApp: App {
#StateObject private var modelData = ModelData()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(modelData)
}
}
}
class ModelData: ObservableObject {
#Published var myLinks = [URL(string: "https://google.com")!, URL(string: "https://apple.com")!, URL(string: "https://amazon.com")!]
}
struct ContentView: View {
#EnvironmentObject var modelData: ModelData
#State private var selected: URL?
var body: some View {
NavigationView {
List(selection: $selected) {
Section(header: Text("Bookmarks")) {
ForEach(modelData.myLinks, id: \.self) { url in
NavigationLink(destination: DetailView(selected: $selected) ) {
Text(url.absoluteString)
}
.tag(url)
}
}
}
.onDeleteCommand {
if let selected = selected {
modelData.myLinks.remove(at: modelData.myLinks.firstIndex(of: selected)!)
}
selected = nil
}
Text("Choose a link")
}
}
}
struct DetailView: View {
#Binding var selected: URL?
var body: some View {
if let selected = selected {
Text("Currently selected: \(selected)")
}
else {
Text("Choose a link")
}
}
}
When I alternate clicking on the second and third links in the sidebar, I eventually start seeing the aforementioned warnings in my console.
Here's a gif of what I'm referring to:
Interestingly, the warning does not appear when alternating clicks between the first and second link.
Does anyone know how to fix this?
I'm using macOS 12.2.1 & Xcode 13.2.1.
Thanks in advance
I think the issue is that both the List(selection:) and the NavigationLink are trying to update the state variable selected at once. A List(selection:) and a NavigationLink can both handle the task of navigation. The solution is to abandon one of them. You can use either to handle navigation.
Since List look good, I suggest sticking with that. The NavigationLink can then be removed. The second view under NavigationView is displayed on the right, so why not use DetailView(selected:) there. You already made the selected parameter a binding variable, so the view will update if that var changes.
struct ContentView: View {
#EnvironmentObject var modelData: ModelData
#State private var selected: URL?
var body: some View {
NavigationView {
List(selection: $selected) {
Section(header: Text("Bookmarks")) {
ForEach(modelData.myLinks, id: \.self) { url in
Text(url.absoluteString)
.tag(url)
}
}
}
.onDeleteCommand {
if let selected = selected {
modelData.myLinks.remove(at: modelData.myLinks.firstIndex(of: selected)!)
}
selected = nil
}
DetailView(selected: $selected)
}
}
}
I can recreate this problem with the simplest example I can think of so my guess is it's an internal bug in NavigationView.
struct ContentView: View {
var body: some View {
NavigationView {
List {
NavigationLink("A", destination: Text("A"))
NavigationLink("B", destination: Text("B"))
NavigationLink("C", destination: Text("C"))
}
}
}
}

SwiftUI problems with Multiplatform NavigationView

I'm currently having all sorts of problems with a NavigationView in my multi-platform SwiftUI app.
My goal is to have a NavigationView with an item for each object in a list from Core Data. And each NavigationLink should lead to a view that can read and write data of the object that it's showing.
However I'm running into many problems, so I figured I'm probably taking the wrong approach.
Here is my code as of now:
struct InstanceList: View {
#StateObject private var viewModel = InstancesViewModel()
#State var selectedInstance: Instance?
var body: some View {
NavigationView {
List {
ForEach(viewModel.instances) { instance in
NavigationLink(destination: InstanceView(instance: instance), tag: instance, selection: $selectedInstance) {
InstanceRow(instance)
}
}
.onDelete { set in
viewModel.deleteInstance(viewModel.instances[Array(set)[0]])
for reverseIndex in stride(from: viewModel.instances.count - 1, through: 0, by: -1) {
viewModel.instances[reverseIndex].id = Int16(reverseIndex)
}
}
}
.onAppear {
selectedInstance = viewModel.instances.first
}
.listStyle(SidebarListStyle())
.navigationTitle("Instances")
.toolbar {
ToolbarItemGroup {
Button {
withAnimation {
viewModel.addInstance(name: "4x4", puzzle: "3x3") // temporary
}
} label: {
Image(systemName: "plus")
}
}
}
}
}
}
and the view model (which probably isn't very relevant but I'm including it just in case):
class InstancesViewModel: ObservableObject {
#Published var instances = [Instance]()
private var cancellable: AnyCancellable?
init(instancePublisher: AnyPublisher<[Instance], Never> = InstanceStorage.shared.instances.eraseToAnyPublisher()) {
cancellable = instancePublisher.sink { instances in
self.instances = instances
}
}
func addInstance(name: String, puzzle: String, notes: String? = nil, id: Int? = nil) {
InstanceStorage.shared.add(
name: name,
puzzle: puzzle,
notes: notes,
id: id ?? (instances.map{ Int($0.id) }.max() ?? -1) + 1
)
}
func deleteInstance(_ instance: Instance) {
InstanceStorage.shared.delete(instance)
}
func deleteInstance(withId id: Int) {
InstanceStorage.shared.delete(withId: id)
}
func updateInstance(_ instance: Instance, name: String? = nil, puzzle: String? = nil, notes: String? = nil, id: Int? = nil) {
InstanceStorage.shared.update(instance, name: name, puzzle: puzzle, notes: notes, id: id)
}
}
and then the InstanceView, which just shows some simple information for testing:
struct InstanceView: View {
#ObservedObject var instance: Instance
var body: some View {
Text(instance.name)
Text(String(instance.id))
}
}
Some of the issues I'm having are:
On iOS and iPadOS, when the app starts, it will show a blank InstanceView, pressing the back button will return to a normal instanceView and pressing it again will show the navigationView
Sometime pressing on a navigationLink will only highlight it and won't go to the destination
On an iPhone in landscape, when scrolling through the NavigationView, sometimes the selected Item will get unselected.
When I delete an item, the InstanceView shows nothing for the name and 0 for the id, as if its showing a "ghost?" instance, until you select a different one.
I've tried binding the selecting using the index of the selected Instance but that still has many of the same problems.
So I feel like I'm making some mistake in the way that I'm using NavigationView, and I was wondering what the best approach would be for creating a navigationView from an Array that works nicely across all devices.
Thanks!

Reload aync call on view state update

I have the following view:
struct SpriteView: View {
#Binding var name: String
#State var sprite: Image = Image(systemName: "exclamationmark")
var body: some View {
VStack{
sprite
}
.onAppear(perform: loadSprite)
}
func loadSprite() {
// async function
getSpriteFromNetwork(self.name){ result in
switch result {
// async callback
case .success(newSprite):
self.sprite = newSprite
}
}
}
What I want to happen is pretty simple: a user modifies name in text field (from parent view), which reloads SpriteView with the new sprite. But the above view doesn't work since when the view is reloaded with the new name, loadSprite isn't called again (onAppear only fires when the view is first loaded). I also can't put loadSprite in the view itself (and have it return an image) since it'll lead to an infinite loop.
There is a beta function onChange that is exactly what I'm looking for, but it's only in the beta version of Xcode. Since Combine is all about async callbacks and SwiftUI and Combine are supposed to play well together, I thought this sort of behavior would be trivial to implement but I've been having a lot of trouble with it.
I don't particular like this solution since it requires creating a new ObservableObject but this how I ended up doing it:
class SpriteLoader: ObservableObject {
#Published var sprite: Image = Image(systemName: "exclamationmark")
func loadSprite(name: String) {
// async function
self.sprite = Image(systemName: "arrow.right")
}
}
struct ParentView: View {
#State var name: String
#State var spriteLoader = SpriteLoader()
var body: some View {
SpriteView(spriteLoader: spriteLoader)
TextField(name, text: $name, onCommit: {
spriteLoader.loadSprite(name: name)
})
}
}
struct SpriteView: View {
#ObservedObject var spriteLoader: SpriteLoader
var body: some View {
VStack{
spriteLoader.sprite
}
}
}
Old answer:
I think the best way to do this is as follows:
Parent view:
struct ParentView: View {
#State var name: String
#State spriteView = SpriteView()
var body: some View {
spriteView
TextField(value: $name, onCommit: {
spriteView.loadSprite(name)
})
}
And then the sprite view won't even need the #Binding name member.

SwiftUI out of index when deleting an array element in ForEach

I looked through different questions here, but unfortunately I couldn't find an answer. This is my code:
SceneDelegate.swift
...
let contentView = ContentView(elementHolder: ElementHolder(elements: ["abc", "cde", "efg"]))
...
window.rootViewController = UIHostingController(rootView: contentView)
ContentView.swift
class ElementHolder: ObservableObject {
#Published var elements: [String]
init(elements: [String]) {
self.elements = elements
}
}
struct ContentView: View {
#ObservedObject var elementHolder: ElementHolder
var body: some View {
VStack {
ForEach(self.elementHolder.elements.indices, id: \.self) { index in
SecondView(elementHolder: self.elementHolder, index: index)
}
}
}
}
struct SecondView: View {
#ObservedObject var elementHolder: ElementHolder
var index: Int
var body: some View {
HStack {
TextField("...", text: self.$elementHolder.elements[self.index])
Button(action: {
self.elementHolder.elements.remove(at: self.index)
}) {
Text("delete")
}
}
}
}
When pressing on the delete button, the app is crashing with a Index out of bounds error.
There are two strange things, the app is running when
1) you remove the VStack and just put the ForEach into the body of the ContentView.swift or
2) you put the code of the SecondView directly to the ForEach
Just one thing: I really need to have the ObservableObject, this code is just a simplification of another code.
UPDATE
I updated my code and changed Text to a TextField, because I cannot pass just a string, I need a connection in both directions.
The issue arises from the order in which updates are performed when clicking the delete button.
On button press, the following will happen:
The elements property of the element holder is changed
This sends a notification through the objectWillChange publisher that is part of the ElementHolder and that is declared by the ObservableObject protocol.
The views, that are subscribed to this publisher receive a message and will update their content.
The SecondView receives the notification and updates its view by executing the body getter.
The ContentView receives the notification and updates its view by executing the body getter.
To have the code not crash, 3.1 would have to be executed after 3.2. Though it is (to my knowledge) not possible to control this order.
The most elegant solution would be to create an onDelete closure in the SecondView, which would be passed as an argument.
This would also solve the architectural anti-pattern that the element view has access to all elements, not only the one it is showing.
Integrating all of this would result in the following code:
struct ContentView: View {
#ObservedObject var elementHolder: ElementHolder
var body: some View {
VStack {
ForEach(self.elementHolder.elements.indices, id: \.self) { index in
SecondView(
element: self.elementHolder.elements[index],
onDelete: {self.elementHolder.elements.remove(at: index)}
)
}
}
}
}
struct SecondView: View {
var element: String
var onDelete: () -> ()
var body: some View {
HStack {
Text(element)
Button(action: onDelete) {
Text("delete")
}
}
}
}
With this, it would even be possible to remove ElementHolder and just have a #State var elements: [String] variable.
Here is possible solution - make body of SecondView undependable of ObservableObject.
Tested with Xcode 11.4 / iOS 13.4 - no crash
struct SecondView: View {
#ObservedObject var elementHolder: ElementHolder
var index: Int
let value: String
init(elementHolder: ElementHolder, index: Int) {
self.elementHolder = elementHolder
self.index = index
self.value = elementHolder.elements[index]
}
var body: some View {
HStack {
Text(value) // not refreshed on delete
Button(action: {
self.elementHolder.elements.remove(at: self.index)
}) {
Text("delete")
}
}
}
}
Another possible solution is do not observe ElementHolder in SecondView... for presenting and deleting it is not needed - also no crash
struct SecondView: View {
var elementHolder: ElementHolder // just reference
var index: Int
var body: some View {
HStack {
Text(self.elementHolder.elements[self.index])
Button(action: {
self.elementHolder.elements.remove(at: self.index)
}) {
Text("delete")
}
}
}
}
Update: variant of SecondView for text field (only changed is text field itself)
struct SecondViewA: View {
var elementHolder: ElementHolder
var index: Int
var body: some View {
HStack {
TextField("", text: Binding(get: { self.elementHolder.elements[self.index] },
set: { self.elementHolder.elements[self.index] = $0 } ))
Button(action: {
self.elementHolder.elements.remove(at: self.index)
}) {
Text("delete")
}
}
}
}

Picker delegate scrolling method in SwiftUI

I have simple Picker object in my SwiftUI hierarchy:
Picker(selection: $pickerSelection, label: Text("Select your item")) {
ForEach(0 ..< items.count) {
Text("\(self.items[$0].valueCode)")
.tag($0)
}
}
I'm using a scrollable Picker in WatchOS app and it works just fine. I'm even getting a Digital Crown rotation capability for free.
What I want to do is to detect when the scrolling started and especially ended (to get last selected value and execute and action with it)
I figure I need to implement sort of Delegate method to read the changes happening to the Picker but I'm not sure how, nor I'm able to find any in the documentation for WKInterfacePicker or just Picker
Any suggestions on how to detect the beginning and end of the scrolling event?
If its about the last value you can use Combine and subscribe to pickerSelection.
class ViewModel: ObservableObject {
private var disposables = Set<AnyCancellable>()
#Published var pickerSelection = 0
init() {
let cc = $pickerSelection
.sink(receiveValue: { value in
print(value)
})
cc.store(in: &disposables)
}
}
struct ContentView: View {
#ObservedObject var mm = ViewModel()
var items = [1,2,3,4,5,6,7,8,9,10]
var body: some View {
VStack {
Text("Hello, World!")
Picker(selection: self.$mm.pickerSelection, label: Text("Item:")) {
ForEach(0 ..< items.count) {
Text("Item \($0)")
.tag($0)
}
}
}
}
}