List ForEach not updating correctly - swiftui

I am using Core data and Swiftui and everything have been working fine but in this one view I have a List, ForEach that is not working.
So for testing purpose my code currently look like this:
#ObservedObject var viewModel = NewLearningWhyViewModel()
VStack {
ForEach(viewModel.whys, id: \.self) { why in
Text(why.why)
}
List {
ForEach(viewModel.whys, id: \.self) { why in
Text(why.why)
}
}
Button(action: {
viewModel.createWhy(why: "Test", count: viewModel.whys.count, learning: learn)
viewModel.fetchWhy(predicate: NSPredicate(format: "parentLearning == %#", learn))
}){
Text("Add")
.font(.title)
.foregroundColor(.white)
}
}
The problem is my List { ForEach, first time I press add button it shows the new why, second time i press the button the whole list goes away, the third time I press the button the list shows again with all 3 items.
To test the problem I added that first ForEach part and that shows the correct item at all times, so there is not a problem with the viewmodel or adding the items, the items are added and it is published from the viewmodel since that part is updated.
Does anyone have any clue why my List { ForEach only show every other time?

I have gotten this problem. I figure it out by adding objectWillChange in ViewModel, and send() it manually when your why is changed. Actually I don't know your NewLearningWhyViewModel clearly, so this is just an example, you should try it out.
class NewLearningWhyViewModel: ObservableObject {
let objectWillChange: ObservableObjectPublisher = ObservableObjectPublisher()
#Published var whys: Why = Why() {
didSet {
objectWillChange.send()
}
}
}

Ok the post from Becky Hansmeyer solved it, adding .id(UUID()) to the list solved it and it started working correctly...

because of "viewModel.whys" is set of classes.
SwiftUI does not work with classes directly.
There is 2 solutions:
make it struct instead of class + add #Published modifier inside of view
leave it as is + do it observable object and in init of your view assign into observed object.
More details here:
https://stackoverflow.com/a/62919526/4423545

Related

How to avoid rebuild view when tap on different button on NavigationSplitView

I have tried apple example
Bringing robust navigation structure to your SwiftUI app
so my code looks like this
NavigationSplitView(
columnVisibility: $navigationModel.columnVisibility
) {
List(
categories,
selection: $navigationModel.selectedCategory
) { category in
NavigationLink(category.localizedName, value: category)
}
.navigationTitle("Categories")
.toolbar {
ExperienceButton(isActive: $showExperiencePicker)
}
} detail: {
NavigationStack(path: $navigationModel.recipePath) {
RecipeGrid(category: navigationModel.selectedCategory)
}
}
Details View
struct RecipeGrid: View {
var category: Category?
var dataModel = DataModel.shared
var body: some View {
ZStack {
if let category = category {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(dataModel.recipes(in: category)) { recipe in
NavigationLink(value: recipe) {
RecipeTile(recipe: recipe)
}
.buttonStyle(.plain)
}
}
.padding()
}
.navigationTitle(category.localizedName)
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe) { relatedRecipe in
NavigationLink(value: relatedRecipe) {
RecipeTile(recipe: relatedRecipe)
}
.buttonStyle(.plain)
}
}
} else {
Text("Choose a category")
.navigationTitle("")
}
}
}
var columns: [GridItem] {
[ GridItem(.adaptive(minimum: 240)) ]
}
}
My issue is if I go to details view then tap on other sidebar item
after that return to same tap, it will return to rootview also onAppear it toggled!
that mean the view did rebuild itself
on Apple News app it will stay on save view
it won't rebuild or return to rootview when I change sidebar item
I want same behavior, but I don't know how can I do it
I didn't find any question here or any article
explain how to do the same behavior as Apple News app
I think the solution is rather complex. I tried modifying the code myself but there’s quite a few things that need to change.
Firstly, the problem is caused because here NavigationStack(path: $navigationModel.recipePath) the recipePath gets reset every time the users taps the sidebar. I wish I understood why, but I passed to path a manual binding Binding.init(get:set:).
Secondly, the route is [Recipe], so when you select another category it makes sense that this route will be emptied. Otherwise, when the user taps the sidebar, the selected item from the previous category would still show.
So you would need the route to be a dictionary ([Category: [Recipe]]) and you need to hold the route for each category.
After I made this change, I saw in Apple Documentation that you can restore the route after the view has appeared, probably this solves the first problem.
So every time the category would change, I would make a copy of the route for that category. Then I tried to restore it when the category was tapped again, but I got stuck here because onAppear wasn’t triggered for me on RecipeGrid(…).onAppear {}.

Why are my SwipeActions overflowing the listview SwiftUI

Why is my SwiftUI Swipe Action behaving like this?
I don't now how to add a GIF in stack overflow so here is a imagur link https://imgur.com/a/9MqjIgX.
If you don't want to click on external links here is a image from the GIF:
My View:
struct MyView: View {
#State var shops = [Shop.empty(), Shop.empty(), Shop.empty(), Shop.empty(), Shop.empty()]
var body: some View {
NavigationView {
List($shops) { $shop in
Text(shop.name)
.swipeActions {
Button {
shop.toggleFavourite()
} label: {
Image(systemName: "star")
}
}
}
}
}
}
the shop struct:
struct Shop: Hashable, Identifiable {
var id: UUID
var favourite: Bool
init(id: UUID){
self.id = id
self.favourite = UserDefaults.standard.bool(forKey: id.uuidString)
}
mutating func toggleFavourite() {
favourite.toggle()
UserDefaults.standard.set(favourite, forKey: id.uuidString)
}
static func empty() -> Shop{
Shop(id: UUID())
}
}
But I can't sadly I can't give you a working example, because I tried to run this code in a fresh app and it worked, without the Bug. On the same device. And I don't understand why, because I also put this view in the root of my old project, just for testing, and the bug stayed there.
I was able to figure out, that if I commented out this line:
UserDefaults.standard.set(favourite, forKey: id.uuidString)
my code would work. But unfortunately I can't just leave out this line of code.
I tried several things, including wrapping this line into DispatchQueue.main.async {} and DispatchQueue.main.sync {}, same with the DispatchQueue.global(). I also added delays. Short delays wouldn't work at all (under .5 seconds) and longer delays would just delay the view bug.
Of course I also tried wrapping this line into a separate function, and so on.
There are two mayor points, why I'am so confused:
Why is the line, that sets this to the Userdefaults even influencing the view? I mean I checked with a print statement, that the initializer, which is the only section in my code that checks this Userdefaultvalue, only gets called when the view gets initialized.
Why does the code work in a different project?
I know since I can't provide a working example of my bug it's hard for you to figure out whats wrong. If you have any ideas, I would be very happy!

Sheet can't be dismissed when removing a tab

In my app, I have two tabs. The second tab is shown or hidden based on some condition. I find if there is a sheet being presented in the second tab when the tab is to be hidden, the sheet can't be dismissed.
The issue can be consistently reproduced with the code below. To reproduce it, click tab 2, then click "Present Sheet", then click "Hide Tab 2". You will see the sheet isn't removed, though the tab containing it (that is, tab 2) has been removed (you can drag the sheet down to verify it).
It seems a SwiftUI bug to me. Does anyone know how to work around it? I'm close to finish my app but hit this unexpected issue :( Any help will be much appreciated.
struct ContentView: View {
#State var showTab2: Bool = true
var body: some View {
TabView {
// tab 1
NavigationView {
Text("Tab 1")
}
.tabItem {
Label("Tab 1", systemImage: "1.circle")
}
// tab 2
if showTab2 {
NavigationView {
Tab2(showTab2: $showTab2)
}
.tabItem {
Label("Tab 2", systemImage: "2.circle")
}
}
}
}
}
struct Tab2: View {
#State var showSheet: Bool = false
#Binding var showTab2: Bool
var body: some View {
VStack(spacing: 12) {
Text("Tab 2")
Button("Click to present sheet") {
showSheet = true
}
}
.sheet(isPresented: $showSheet, onDismiss: nil) {
NavigationView {
MySheet(showTab2: $showTab2)
}
}
}
}
struct MySheet: View {
#Environment(\.dismiss) var dismiss
#Binding var showTab2: Bool
var body: some View {
Button("Click to hide tab 2") {
// dismiss() works fine if I comment out this line.
showTab2 = false
dismiss()
}
}
}
I have submitted feedback on this to Apple, but I'm not optimistic for any reply (I have never received one).
Update:
The issue can be reproduced in many other scenarios where no sheet is involved. So, the second approach #Asperi gave is not a general solution.
Well, here we see conflict of actions (due to racing): async sheet closing (due to animation) and sync tab removing.
Here are possible approaches:
delay tab removing after sheet closed (implicit way)
Button("Click to hide tab 2") {
dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { // << here !!
showTab2 = false
}
}
remove tab after sheet closed (explicit way)
.sheet(isPresented: $showSheet, onDismiss: { showTab2 = false }) { // << here !!
NavigationView {
MySheet(showTab2: $showTab2)
}
}
Note: Actually when view knows/manages something for parent of parent is not very good design, so option 2 (maybe with some additional conditions/callbacks) are more preferable.
#Asperi gave a great answer. But it's not straightforward to apply his approaches in actual app. I'll explain why and how to do it below.
The key idea in Asperi's approaches is that, since the UI changes have race condition, they should be performed in two steps. In both approaches the sheet is dismissed first, then the tab is hidden.
In practice, however, it may not be obvious how to decouple the two steps. For example, my app works this way (I think it's typical):
The sheet contains a form and call data model API to mutate data model when the form is submitted by user.
Since the data model API may fail, the sheet doesn't dismiss itself as soon as user submits the form. Instead it does that only when the API call succeeds (the API call is synchronous).
When the data model is mutated, it may trigger the condition to hide the tab.
Note the item 2 and 3. It means the sheet have to call data model API first, which may hide the tab, and then dismiss itself.
It took me a while to think out the solution - introduce a dedicated state to control show/hide the tab and hence decouple the two steps. Now the issue left is how to synchronize data model change to that state. Since the purpose is to make them to appear as two separate changes to UI, we can't use Combine. It can be messy if not implemented property because data model can be mutated from everywhere (e.g. Form, ActionSheet, or just Button). Fortunately I find a very elegant approach:
.onChange(of: model.showTab2) { value in
// In my experiments async() works fine, but just to be on the safe side...
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
// This is a state outside data model. It hides/shows tab2.
showTab2 = value
}
}
This is another example that there is no problem that can't be solved by adding another layer of abstraction :)

Finding the accessibility(identifier:) of DatePicker() embedded in a Form in SwiftUI using an XCUIElementQuery

I have a SwiftUI view that has a Form that contains a DatePicker:
struct GettingUpTimeSettingView: View {
#State private var viewModel = GettingUpTimeSettingViewModel()
var body: some View {
VStack {
Form {
Spacer()
Text(viewModel.questionString)
.accessibility(identifier: "Question")
Spacer()
DatePicker("Alarm Time",
selection: $viewModel.gettingUpTime,
displayedComponents: .hourAndMinute)
.accessibility(identifier: "Time")
.animation(.easeInOut)
Spacer()
Text(viewModel.explanationString)
.accessibility(identifier: "Explanation")
}
}
}
}
And an XCTestCase class for UI Testing:
class SleepyGPIntroUITests: XCTestCase {
private var app: XCUIApplication!
override func setUp() {
continueAfterFailure = false
app = XCUIApplication()
app.launch()
greeting = app.staticTexts["Greeting"]
}
override func tearDown() {
app = nil
}
func test_InitialScreen_ChangesTo_GettingUpScreen_Automatically() {
//Given
let questionText = "What time do you want to get up?"
let explanationText = "This should be the same time every day, including at weekends and on days off. Our best chance of great sleep comes when we have a regular routine."
let timeText = "7:00am"
let question = app.staticTexts["Question"]
let explanation = app.staticTexts["Explanation"]
let time = app.staticTexts["Time"]
//When
_ = time.waitForExistence(timeout: 2)
//Then
XCTAssertFalse(greeting.exists)
XCTAssertEqual(question.label, questionText, "Should show question at top of view")
XCTAssertEqual(explanation.label, explanationText, "Should show explanation in view")
XCTAssertEqual(time.label, timeText, "Should show the correct default time")
}
When I run the test, it fails and gives me this message:
Failed to get matching snapshot: No matches found for Elements matching predicate '"Time" IN identifiers' from input {(
StaticText, identifier: 'Question', label: 'What time do you want to get up?',
StaticText, identifier: 'Explanation', label: 'This should be the same time every day, including at weekends and on days off. Our best chance of great sleep comes when we have a regular routine.'
)}
I'm not sure whether this is down to the DatePicker() being contained in a Form, or whether it's because I'm not using the correct XCUIElementQuery to find it?
When I move the DatePicker outside the Form, I can find it, but only by using its label, and not its accessibility identifier.
The accessibility identifiers are working fine for the Text objects.
I can also find it using
app.datePickers.firstMatch
when it's outside the form, but not when it's contained within it.
I've found this answer that describes some odd behaviour when SwiftUI objects are contained in forms, but still haven't managed to solve my problem.
Many thanks.
tl:dr Can't get XCUIElementQuery to return DatePicker element when UITesting if the DatePicker is contained in a SwiftUI Form.
I've found the answer, for anyone that's interested.
First of all, a piece of basic advice, if you're struggling to find out how to access an element when UI testing in XCode, just use the record function and access the element manually.
That's what I did, and it showed me that before a DatePicker() is tapped on in SwiftUI, it actually shows as a button, so to access it in the example above I used this code:
let alarmTimeButton = app.tables.buttons["Time"]

SwiftUI how to detect when a view has the focus?

I have two List's in a view and want to be able to determine which list currently has the focus in order to show the correct details of the selected item in the list in a details panel.
The following code never seems to get called, can anyone indicate whether there is another correct way to determine when focus changes.
struct StoreList: View {
#EnvironmentObject private var database: Database
#Binding var selectedStore: Store?
var body: some View {
List(selection: $selectedStore) {
ForEach(database.stores, id: \.self) { store in
StoreRow(store: store).tag(store)
.focusable(true, onFocusChange: { isFocused in
print("focus changed")
if isFocused {
self.database.selectedType = .store
}
})
}
}
.focusable(true, onFocusChange: { isFocused in
print("focus changed")
if isFocused {
self.database.selectedType = .store
}
})
}
}
In the meantime I will explore detecting mouse clicks on the Rows since the user would need to click on an item in the list to move the focus.
Currently I am setting the selectedType value when an item changes (i.e. $selectedStore) in the view model (database) but if the user selected the already selected item in the other list then the value does not get updated but the List and list item does get the focus - well the visual colour change indicates it has the focus.
EDIT:
I have also tried processing the onTapGesture callback which works fine except it replaces the List rows default behaviour. How can I make sure the event is passed through to the List as this might work then.
The easiest method of reacting to focus is
struct DummyView: View {
#Environment(\.isFocused) var isFocused
var body: some View {
Text("My view")
.padding()
.background(isFocused ? Color.orange : Color.black)
}
}