SwiftUI View does not updated when ObservedObject changed - swift

I have a content view where i'm showing a list of items using ForEach
#ObservedObject var homeVM : HomeViewModel
var body: some View {
ScrollView (.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
Spacer().frame(width:8)
ForEach(homeVM.favoriteStores , id: \._id){ item in
StoreRowOneView(storeVM: item)
}
Spacer().frame(width:8)
}
}.frame(height: 100)
}
And my ObservableObject contains
#Published var favoriteStores = [StoreViewModel]()
And StoreViewModel is defined as below
class StoreViewModel : ObservableObject {
#Published var store:ItemStore
init(store:ItemStore) {
self.store = store
}
var _id:String {
return store._id!
}
}
If i fill the array directly it work fine , and i can see the list of stores
but if i filled the array after a network call (background job) , the view did not get notified that there is a change
StoreApi().getFavedStores(userId: userId) { (response) in
guard response != nil else {
self.errorMsg = "Something wrong"
self.error = true
return
}
self.favoriteStores = (response)!.map(StoreViewModel.init)
print("counnt favorite \(self.favoriteStores.count)")
}
But the weird thing is :
- if i add a text which show the count of the array items , the view will get notified and everything work fine
That's what i mean :
#ObservedObject var homeVM : HomeViewModel
var body: some View {
ScrollView (.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
Spacer().frame(width:8)
ForEach(homeVM.favoriteStores , id: \._id){ item in
StoreRowOneView(storeVM: item)
}
Text("\(self.homeVM.favoriteStores.count)").frame(width: 100, height: 100)
Spacer().frame(width:8)
}
}.frame(height: 100)
}
Any explaining for that ?

For horizontal ScrollView you need to have your height fixed or just use conditional ScrollView which will show when someModels.count > 0
var body: some View {
ScrollView(.horizontal) {
HStack() {
ForEach(someModels, id: \.someParameter1) { someModel in
someView(someModel)
}
}.frame(height: someFixedHeight)
}
}
// or
var body: some View {
VStack {
if someModels.count > 0 {
ScrollView(.horizontal) {
HStack() {
ForEach(someModels, id: \.someParameter1) { someModel in
someView(someModel)
}
}
}
} else {
EmptyView()
// or other view
}
}
}
Properly conforming to Hashable protocol by implementing
func hash(into hasher: inout Hasher) {
hasher.combine(someParameter1)
hasher.combine(someParameter2)
}
and
static func == (lhs: SomeModel, rhs: SomeModel) -> Bool {
lhs.someParameter1 == rls.someParameter1 && lhs.someParameter2 == rls.someParameter2
}
And also following #Asperi suggestion of using proper id from model, which must be unique to every element.
ForEach(someModels, id: \.someParameter1) { someModel in
someView(someModel)
}
There is no other way for ForEach to notify updates other than unique ids.
Text(someModels.count) was working because it is notifying the changes as count is being changed.

Share some experiences that might help.
When I used ScrollView + ForEach + fetch, nothing shows until I trigger view refresh via other means. It shows normally when I supply fixed data without fetching. I solved it by specifying .infinity width for ScrollView.
My theory is that ForEach re-renders when fetch completes, but ScrollView somehow does not adjust its width to fit the content.
It is similar in your case. By supplying fixed width Text, ForEach can calculate its width during init, and ScrollView can use that to adjust its width. But in case of fetch, ForEach has initial 0 content width, and so is ScrollView. When fetch completes ScrollView does not update its width accordingly.
Giving ScrollView some initial width should solve your problem.

I would start from forwarding results to the main queue, as below
DispatchQueue.main.async {
self.favoriteStores = (response)!.map(StoreViewModel.init)
}
Next... ForEach tracks id parameters, so you should be sure that results have different _id s in the following, otherwise ForEach is not marked as needed to update
ForEach(homeVM.favoriteStores , id: \._id){ item in
StoreRowOneView(storeVM: item)
}

What is ItemStore? more importantly
ForEach(homeVM.favoriteStores , id: \._id){ item in
StoreRowOneView(storeVM: item)
}
what is _id i believe your _id is same each time so it will not update the list. You have to put id: some variable that you want to re-render the list when it changed.
Update: If you do not have any other variable to take its value as reference than you can use
ForEach((0..<homeVM.favoriteStores.count)) { index in
StoreRowOneView(storeVM:: homeVM.favoriteStores[index])
}

Related

Update View on Realm Object Change

I am updating an object in my Realm Database but I am having a hard time getting it to reload any of my views as a result. R.realm is a wrapper around realm. Both it and ActiveManager are working correctly as I can look at the results in Realm Studio and see them updating. How can I trigger a refresh when returning to the Items screen after toggling active on the Item screen? Is there anyway of just adding some kind of observer to the App entry point so that whenever the Realm database is changed for any reason it invalidates all the views and causes a refresh everywhere? I'm coming from a typescript/react background so I'm having a hard time wrapping my head around the way swift is handling all this. Code below, I've truncated irrelevant parts for brevity
ManageView
struct ManageView: View {
var body: some View {
NavigationView {
List {
NavigationLink(destination: ItemsView(kind: ITEM_KIND.Area)) {
Text("Areas")
}
NavigationLink(destination: ItemsView(
kind: ITEM_KIND.Scheme
)) {
Text("Schemes")
}
ItemsView
struct ItemsView: View {
#ObservedResults(Active.self) var active
#State var showAddItemModal: Bool = false
var kind: ITEM_KIND
var body: some View {
VStack {
List {
Section("Current") {
ForEach(getCurrent(), id: \._id) { item in
VStack {
NavigationLink(destination: ItemView(item: item)) {
Text("\(item.title)")
}
}
}
...
func getCurrent() -> Results<Item> {
let currentPeriod = R.realm.getByKey(Period.self, key: ActiveManager.shared.getPeriod())!
return R.realm.getWhere(Item.self) { item in item.kind == self.kind && item._id.in(currentPeriod.items) }
}
ItemView
struct ItemView: View {
#ObservedRealmObject var item: Item
#State var isActive: Bool = false
func viewWillAppear() {
print("appear")
isActive = ActiveManager.shared.getItems().contains(item._id)
}
var body: some View {
ScrollView {
VStack {
ZStack {
Toggle("Active", isOn: $isActive)
.padding(.horizontal)
.onChange(of: isActive) { value in
if value {
ActiveManager.shared.addItem(item: item)
} else {
ActiveManager.shared.removeAllItems()
}
}
Will come back and post a more thorough solution at a later date but I couldn't get Realm's property wrappers to work so in the top Manage View I created some observable objects that contained the item arrays wrapped with the #Published wrapper and then some functions for updating the arrays. I then passed these observable objects down into Items View and again into Item view. Whenever I changed my item I then used one of the update array functions on the observable object to trigger a state refresh.
item.realm != nil
is a solution wich work fine for me, but it throws a warning. I call it after dismissing a sheet.

LazyVStack - row onAppear is called early

I have a LazyVStack, with lots of rows. Code:
struct ContentView: View {
var body: some View {
ScrollView {
LazyVStack {
ForEach(0 ..< 100) { i in
Text("Item: \(i + 1)")
.onAppear {
print("Appeared:", i + 1)
}
}
}
}
}
}
Only about 40 rows are visible on the screen initially, yet onAppear is triggered for 77 rows. Why is this, why is it called before it is actually visible on the screen? I don't see why SwiftUI would have to 'preload' them.
Is there a way to fix this, or if this is intended, how can I accurately know the last visible item (accepting varying row heights)?
Edit
The documentation for LazyVStack states:
The stack is “lazy,” in that the stack view doesn’t create items until it needs to render them onscreen.
So this must be a bug then, I presume?
By words from the documentation, onAppear shouldn't be like this:
The stack is “lazy,” in that the stack view doesn’t create items until it needs to render them onscreen.
However, if you are having problems getting this to work properly, see my solution below.
Although I am unsure why the rows onAppears are triggered early, I have created a workaround solution. This reads the geometry of the scroll view bounds and the individual view to track, compares them, and sets whether it is visible or not.
In this example, the isVisible property changes when the top edge of the last item is visible in the scroll view's bounds. This may not be when it is visible on screen, due to safe area, but you can change this to your needs.
Code:
struct ContentView: View {
#State private var isVisible = false
var body: some View {
GeometryReader { geo in
ScrollView {
LazyVStack {
ForEach(0 ..< 100) { i in
Text("Item: \(i + 1)")
.background(tracker(index: i))
}
}
}
.onPreferenceChange(TrackerKey.self) { edge in
let isVisible = edge < geo.frame(in: .global).maxY
if isVisible != self.isVisible {
self.isVisible = isVisible
print("Now visible:", isVisible ? "yes" : "no")
}
}
}
}
#ViewBuilder private func tracker(index: Int) -> some View {
if index == 99 {
GeometryReader { geo in
Color.clear.preference(
key: TrackerKey.self,
value: geo.frame(in: .global).minY
)
}
}
}
}
struct TrackerKey: PreferenceKey {
static let defaultValue: CGFloat = .greatestFiniteMagnitude
static func reduce(value: inout Value, nextValue: () -> Value) {
value = nextValue()
}
}
It works as per my comments above.
struct ContentView: View {
var body: some View {
ScrollView {
LazyVStack {
ForEach(0 ..< 100) { i in
Text("Item: \(i + 1)")
.id(i)
.frame(width: 100, height: 100)
.padding()
.onAppear { print("Appeared:", i + 1) }
}
}
}
}
}
It seems incredible but just adding a GeometryReader containing your ScrollView would resolve the issue
GeometryReader { _ in
ScrollView(.vertical, showsIndicators: false) {
LazyVStack(spacing: 14) {
Text("Items")
LazyVStack(spacing: 16) {
ForEach(viewModel.data, id: \.id) { data in
MediaRowView(data: data)
.onAppear {
print(data.title, "item appeared")
}
}
if viewModel.state == .loading {
ProgressView()
}
}
}
.padding(.horizontal, 16)
}
}

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!

SwiftUI - Scroll a list to a specific element controlled by external variable

I have a List populated by Core Data, like this:
#EnvironmentObject var globalVariables : GlobalVariables
#Environment(\.managedObjectContext) private var coreDataContext
#FetchRequest(fetchRequest: Expressao.getAllItemsRequest())
private var allItems: FetchedResults<Expressao>
var body: some View {
ScrollViewReader { proxy in
List {
ForEach(allItems,
id: \.self) { item in
Text(item.term!.lowercased())
.id(allItems.firstIndex(of:item))
.listRowBackground(
Group {
if (globalVariables.selectedItem == nil) {
Color(UIColor.clear)
} else if item == globalVariables.selectedItem {
Color.orange.mask(RoundedRectangle(cornerRadius: 20))
} else {
nextAlternatedColor(item:item)
}
}
}
}
}
}
}
Every time a row is selected it changes color to orange. So, you see that the color is controlled by an external variable located in globalVariables.selectedItem.
I want to be able to make the list scroll to that element on globalVariables.selectedItem automatically.
How do I do that with ScrollViewReader?
Any ideas?
Here is a demo of possible approach - scrollTo can be used only in closure, so the idea is to create some background view depending on row to be scrolled to (this can be achieved with .id) and attach put .scrollTo in .onAppear of that view.
Tested with Xcode 12 / iOS 14.
struct DemoView: View {
#State private var row = 0
var body: some View {
VStack {
// button here is generator of external selection
Button("Go \(row)") { row = Int.random(in: 0..<50) }
ScrollViewReader { proxy in
List {
ForEach(0..<50) { item in
Text("Item \(item)")
.id(item)
}
}
.background( // << start !!
Color.clear
.onAppear {
withAnimation {
proxy.scrollTo(row, anchor: .top)
}
}.id(row)
) // >> end !!
}
}
}
}

In SwiftUI, where are the control events, i.e. scrollViewDidScroll to detect the bottom of list data

In SwiftUI, does anyone know where are the control events such as scrollViewDidScroll to detect when a user reaches the bottom of a list causing an event to retrieve additional chunks of data? Or is there a new way to do this?
Seems like UIRefreshControl() is not there either...
Plenty of features are missing from SwiftUI - it doesn't seem to be possible at the moment.
But here's a workaround.
TL;DR skip directly at the bottom of the answer
An interesting finding whilst doing some comparisons between ScrollView and List:
struct ContentView: View {
var body: some View {
ScrollView {
ForEach(1...100) { item in
Text("\(item)")
}
Rectangle()
.onAppear { print("Reached end of scroll view") }
}
}
}
I appended a Rectangle at the end of 100 Text items inside a ScrollView, with a print in onDidAppear.
It fired when the ScrollView appeared, even if it showed the first 20 items.
All views inside a Scrollview are rendered immediately, even if they are offscreen.
I tried the same with List, and the behaviour is different.
struct ContentView: View {
var body: some View {
List {
ForEach(1...100) { item in
Text("\(item)")
}
Rectangle()
.onAppear { print("Reached end of scroll view") }
}
}
}
The print gets executed only when the bottom of the List is reached!
So this is a temporary solution, until SwiftUI API gets better.
Use a List and place a "fake" view at the end of it, and put fetching logic inside onAppear { }
You can to check that the latest element is appeared inside onAppear.
struct ContentView: View {
#State var items = Array(1...30)
var body: some View {
List {
ForEach(items, id: \.self) { item in
Text("\(item)")
.onAppear {
if let last == self.items.last {
print("last item")
self.items += last+1...last+30
}
}
}
}
}
}
In case you need more precise info on how for the scrollView or list has been scrolled, you could use the following extension as a workaround:
extension View {
func onFrameChange(_ frameHandler: #escaping (CGRect)->(),
enabled isEnabled: Bool = true) -> some View {
guard isEnabled else { return AnyView(self) }
return AnyView(self.background(GeometryReader { (geometry: GeometryProxy) in
Color.clear.beforeReturn {
frameHandler(geometry.frame(in: .global))
}
}))
}
private func beforeReturn(_ onBeforeReturn: ()->()) -> Self {
onBeforeReturn()
return self
}
}
The way you can leverage the changed frame like this:
struct ContentView: View {
var body: some View {
ScrollView {
ForEach(0..<100) { number in
Text("\(number)").onFrameChange({ (frame) in
print("Origin is now \(frame.origin)")
}, enabled: number == 0)
}
}
}
}
The onFrameChange closure will be called while scrolling. Using a different color than clear might result in better performance.
edit: I've improved the code a little bit by getting the frame outside of the beforeReturn closure. This helps in the cases where the geometryProxy is not available within that closure.
I tried the answer for this question and was getting the error Pattern matching in a condition requires the 'case' keyword like #C.Aglar .
I changed the code to check if the item that appears is the last of the list, it'll print/execute the clause. This condition will be true once you scroll and reach the last element of the list.
struct ContentView: View {
#State var items = Array(1...30)
var body: some View {
List {
ForEach(items, id: \.self) { item in
Text("\(item)")
.onAppear {
if item == self.items.last {
print("last item")
fetchStuff()
}
}
}
}
}
}
The OnAppear workaround works fine on a LazyVStack nested inside of a ScrollView, e.g.:
ScrollView {
LazyVStack (alignment: .leading) {
TextField("comida", text: $controller.searchedText)
switch controller.dataStatus {
case DataRequestStatus.notYetRequested:
typeSomethingView
case DataRequestStatus.done:
bunchOfItems
case DataRequestStatus.waiting:
loadingView
case DataRequestStatus.error:
errorView
}
bottomInvisibleView
.onAppear {
controller.loadNextPage()
}
}
.padding()
}
The LazyVStack is, well, lazy, and so only create the bottom when it's almost on the screen
I've extracted the LazyVStack plus invisible view in a view modifier for ScrollView that can be used like:
ScrollView {
Text("Some long long text")
}.onScrolledToBottom {
...
}
The implementation:
extension ScrollView {
func onScrolledToBottom(perform action: #escaping() -> Void) -> some View {
return ScrollView<LazyVStack> {
LazyVStack {
self.content
Rectangle().size(.zero).onAppear {
action()
}
}
}
}
}