Strange behavior of SwiftUI TextField (MacOS) - swift

I'm experiencing a strange behaviour of TextFields using SwiftUI on a MacOS application (XCode 11.0 stable, CoreData), probably due to some misconceptions on my side regarding SwiftUI dataflow.
Implementation
Please consider this basic Master/Detail View setup:
Model: Account
Container: ContentView
Master: AccountListView
Detail: AccountEditView
final class Account: NSManagedObject, Identifiable {
#NSManaged var id: UUID
#NSManaged var name: String
}
struct ContentView: View {
var body: some View {
NavigationView {
AccountListView()
.listStyle(SidebarListStyle())
}
.navigationViewStyle(DoubleColumnNavigationViewStyle())
.frame(minWidth: 300, idealWidth: nil, maxWidth: nil, minHeight: 300, idealHeight: nil, maxHeight: nil, alignment: .topLeading)
}
}
struct AccountListView: View {
#FetchRequest(entity: Account.entity(), sortDescriptors: [])
var accounts: FetchedResults<Account>
var body: some View {
VStack {
List(accounts) { account in
NavigationLink (destination: AccountEditView(account: account)) {
Text(account.name)
}
}
}
}
}
struct AccountEditView: View {
#ObservedObject var account: Account
var body: some View {
VStack {
TextField("Account Name", text: $account.name)
Spacer()
}
}
}
Issue
The views are populated correctly. Selecting an account in list view properly updates the textfield in detail view.
However as soon as I edit the textfield value, things go strange:
When the detail view is newly updated, the textfield loses focus after the first entered character. The account name in list view is properly updated (showing the single character entered)
When I'm entering the textfied again, I can type more characters without losing focus (again, the account name in the list view is update)
As soon as I press enter, the TextField's value restores to the old value, while the list view's value remains at the updated value.
When switching back and forth between different account entries, the detail view shows the correct values. So the model object was properly updated.
My questions:
Why does the TextField lose focus when entering a single character for the first time
Why does the TextField restore it's previous value (and not the model value) when commiting the change
Sorry for the lengthy question...

The coredata attributes are a different kind of optionals. So we need to define getter and setter inside the Model class to convert their behavior.
If your Entity's code gen is not manual/none, add this following code in contentview
extension Account {
var wrappedName: String {
get { name ?? "Unknown"}
set { name = newValue }
}
}
then replace all the account.name with account.wrappedName
P.S. This is tested and worked in XCode 11.4 on MacOS 10.5

Related

SwiftUI: Editable TextFields in Lists

*** NOTE: This question concerns macOS, not iOS ***
Context:
I have a list of items that serves as a "Master" view in a standard Master-Detail arrangement. You select an item from the list, and the detail view updates:
In SwiftUI, this is powered by a List like this:
struct RuleListView: View
{
#State var rules: [Rule]
#State var selectedRuleUUID: UUID?
var body: some View
{
List(rules, id: \.uuid, selection: $selectedRuleUUID) { rule in
RuleListRow(rule: rule, isSelected: (selectedRuleUUID == rule.uuid))
}
}
}
The Problem:
The name of each item in the list is user-editable. In AppKit, when using NSTableView or NSOutlineView for the list, the first click on a row selects that row and the second click would then begin editing the NSTextField that contains the name.
In SwiftUI, things have apparently gotten dumber. Clicking on ANY text in the list immediately begins editing that text, which makes selecting a row VERY difficult—you have to click on the 2 pixels above or below the TextField in each row.
To combat this, I've stashed a clear Button() on top of every row that's not selected. This button intercepts the click before it reaches the TextField() and selects the row. When the row is selected, the Button() is no longer included and subsequent clicks then select the TextField():
struct RuleListRow: View
{
var rule: Rule
var isSelected: Bool
var body: some View
{
ZStack
{
Label {
TextField("", text: $rule.name)
.labelsHidden()
} icon: {
Image(systemName: "lasso.sparkles")
}
if !isSelected
{
Button {
// No-op
} label: {
EmptyView()
}
.buttonStyle(.plain)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.clear)
.contentShape(Rectangle())
}
}
}
}
Question:
The above works, but...this can't be correct, right? I've missed something basic that makes List behave properly when it contains TextField rows, right? There's some magic viewModifier I'm supposed to stick somewhere, I'm sure.
What is the correct, canonical way to solve this issue using Swift 5.5 and targeting macOS 11.0+?
Note:
My first approach was to simply disable the TextField when the row isn't selected, but that caused the text to appear "dimmed" and I couldn't find a way to override the text color when the TextField is disabled.

Swift: Struct instances not updating view

The following code is simplified and isolated. It is intended to have a Text() view, which shows the number of times the button has been clicked, and a Button() to increment the text view.
The issue: Clicking the button does not actually change the Text() view, and it continues to display "1"
struct Temp: View {
#State var h = struct1()
var body: some View {
VStack {
Button(action: {
h.num += 1
}, label: {
Text("CLICK ME")
})
Text(String(h.num))
}
}
}
struct struct1 {
#State var num = 1
}
What's funny is that the same code works in swift playgrounds (obviously with the Text() changed to print()). So, I'm wondering if this is an XCode specific bug? If not, why is this happening?
Remove #State from the variable in struct1
SwiftUI wrappers are only for SwiftUI Views with the exception of #Published inside an ObservableObject.
I have not found this in any documentation explicitly but the wrappers conform to DynamicProperty and of you look at the documentation for that it says that
The view gives values to these properties prior to recomputing the view’s body.
So it is implied that if the wrapped variable is not in a struct that is also a SwiftUI View it will not get an updated value because it does not have a body.
https://developer.apple.com/documentation/swiftui/dynamicproperty
The bug is that it works in Playgrounds but Playground seems to have a few of these things. Likely because of the way it complies and runs.

#Published in an ObservableObject vs #State on a View leads to unpredictable update behavior in SwiftUI

This question is coming on the heels of this question that I asked (and had answered by #Asperi) yesterday, but it introduces a new unexpected element.
The basic setup is a 3 column macOS SwiftUI app. If you run the code below and scroll the list to an item further down the list (say item 80) and click, the List will re-render and occasionally "jump" to a place (like item 40), leaving the actual selected item out of frame. This issue was solved in the previous question by encapsulating SidebarRowView into its own view.
However, that solution works if the active binding (activeItem) is stored as a #State variable on the SidebarList view (see where I've marked //#1). If the active item is stored on an ObservableObject view model (see //#2), the scrolling behavior is affected.
I assume this is because the diffing algorithm somehow works differently with the #Published value and the #State value. I'd like to figure out a way to use the #Published value since the active item needs to be manipulated by the state of the app and used in the NavigationLink via isActive: (say if a push notification comes in that affects it).
Is there a way to use the #Published value and not have it re-render the whole List and thus not affect the scrolled position?
Reproducible code follows -- see the commented line for what to change to see the behavior with #Published vs #State
struct Item : Identifiable, Hashable {
let id = UUID()
var name : String
}
class SidebarListViewModel : ObservableObject {
#Published var items = Array(0...300).map { Item(name: "Item \($0)") }
#Published var activeItem : Item? //#2
}
struct SidebarList : View {
#StateObject private var viewModel = SidebarListViewModel()
#State private var activeItem : Item? //#1
var body: some View {
List(viewModel.items) {
SidebarRowView(item: $0, activeItem: $viewModel.activeItem) //change this to $activeItem and the scrolling works as expected
}.listStyle(SidebarListStyle())
}
}
struct SidebarRowView: View {
let item: Item
#Binding var activeItem: Item?
func navigationBindingForItem(item: Item) -> Binding<Bool> {
.init {
activeItem == item
} set: { newValue in
if newValue {
activeItem = item
}
}
}
var body: some View {
NavigationLink(destination: Text(item.name),
isActive: navigationBindingForItem(item: item)) {
Text(item.name)
}
}
}
struct ContentView : View {
var body: some View {
NavigationView {
SidebarList()
Text("No selection")
Text("No selection")
.frame(minWidth: 300)
}
}
}
(Built and tested with Xcode 13.0 on macOS 11.3)
Update. I still think that the original answer identified the problem, however seems that there's an even easier workaround to this: push the view model one level upstream, to the root ContentView, and inject the items array to the SidebarList view.
Thus, the following changes should fix the "jumping" issue:
struct SidebarList : View {
let items: [Item]
#Binding var activeItemId: UUID?
// ...
}
// ...
struct ContentView : View {
#StateObject private var viewModel = SidebarListViewModel()
var body: some View {
NavigationView {
SidebarList(items: viewModel.items,
activeItemId: $viewModel.activeItemId)
// ...
}
For some reason, this works, I don't have an explanation why. However, there's one problem left, that's caused by SwiftUI: programatically changing the selection won't make the list scroll to the new selection. Scroll SwiftUI List to new selection might help fixing this too.
Also, warmly recommending to move the NavigationLink from the body of SidebarRowView to the List part of SidebarList, this will help you limit the amount of details that get leaked to the row view.
Another recommendation I would make, would be to use the tag:selection: alternative to isActive. This works better when you have a pool of possible navigation links from which only one can be active at a certain time. This involves of course changing the view model from var activeItem: Item? to var activeItemId: UUID?, this will avoid the need of the hacky navigationBindingForItem function:
class SidebarListViewModel : ObservableObject {
#Published var items = // ...
#Published var activeItemId : UUID?
}
// ...
NavigationLink(destination: ...,
tag: item.id,
selection: $activeItemId) {
Original Answer
This is most likely what's causing the problematic behaviour:
func navigationBindingForItem(item: Item) -> Binding<Bool> {
.init {
activeItem == item
} set: { newValue in
if newValue {
activeItem = item
}
}
}
If you put a breakpoint on the binding setter, you'll see that the setter gets called every time you select something, and if you also print the item name, you'll see that when the problematic scrolling happens, it always scroll to the previous selected item.
Seems this "manual" binding interferes with the SwiftUI update cycle, causing the framework to malfunction.
The solution here is simple: remove the #Binding declaration from the activeItem property, and keep it as a "regular" one. You also can safely remove the isActive argument passed to the navigation link.
Bindings are needed only when you need to update values in parent components, most of the time simple values are enough. This also makes your views simpler, and more in line with the Swift/SwiftUI principles of using immutable values as much as possible.

Why does my SwiftUI View not update on updating of an #State var?

I am having a strange issue with an #State var not updating an iOS SwiftUI view.
I have an edit screen for themes for a small game with a NavigationView with a list of game themes. When in edit mode and I select one of these themes, I open up an editor view, passing the theme as a binding to the editor view struct.
In my editor view I then have sections that allow the user to edit properties of the theme. I do not want to use bindings to the various theme properties in my edit fields because I do not want the changes to take effect immediately. Instead, I have created #State vars for each of these properties and then use bindings to these in the edit fields. That way, I give the user the option to either cancel without and changes taking effect, or select "Done" to assign the changes back to the theme via the binding.
In order to initialise the #State vars I have an onAppear block that assign the #State vars values from the respective theme properties.
The issue I am having is that when the onAppear block is executed and the vars are assigned, the relevant edit fields are not updating!
Here is a cut-down version of my code:
struct EditorView: View {
/// The current presentation mode of the view.
#Environment(\.presentationMode) var presentationMode
#Binding var theme: GameTheme
#State private var name = ""
...
var body: some View {
NavigationView {
Form {
nameSection
...
}
.navigationTitle("Edit \(theme.name)")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel", action: cancel)
}
ToolbarItem(placement: .confirmationAction) {
Button("Done", action: saveTheme)
.disabled(!canSaveTheme)
}
}
.onAppear {
name = theme.name
...
}
}
.frame(minWidth: Constants.minViewSize.width, minHeight: Constants.minViewSize.height)
}
var nameSection: some View {
Section(header: Text("Name")) {
TextField(LocalizedStringKey("Name"), text: $name)
}
}
...
}
So the view gets shown an on appearing, the #State var name does correctly get assigned the value from theme.name; however, this allocation does not cause an update of the view and the value of "name" is not entered into the TextField.
Interestingly, and I do not know if this is a good thing to do, if I wrap the contents of the onAppear block in a DispatchQueue.main.async, everything works fine!
i.e.
.onAppear {
DispatchQueue.main.async {
name = theme.name
...
}
}
Does anyone have any idea as to how, within the onAppear, I can force a view refresh? Or, why the assignment to "name" does not force an update?
Thanks.
This isn't the answer per se, but I went ahead and created a new iOS project with the following code (based on your post, but I cleaned it up a bit and came up with the missing GameTheme object myself).
It's more or less the same, and shows that your posted structure does re-render.
I'm wondering if there's more to the code we can't see in your post that could be causing this.
Are you possibly setting the name state variable anywhere else in a way that could be overriding the value on load?
import SwiftUI
#main
struct TestIOSApp: App {
#State var gameTheme: GameTheme = GameTheme(name: "A game theme")
var body: some Scene {
WindowGroup {
ContentView(theme: $gameTheme)
}
}
}
struct GameTheme {
var name:String;
}
struct ContentView: View {
#Binding var theme:GameTheme;
/// The current presentation mode of the view.
#Environment(\.presentationMode) var presentationMode
#State private var name = "DEFAULT SHOULD NOT BE DISPLAYED"
var body: some View {
NavigationView {
Form {
nameSection
}
.navigationTitle("Edit \(theme.name)")
.onAppear {
name = theme.name
}
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel", action: {})
}
ToolbarItem(placement: .confirmationAction) {
Button("Done", action: {})
}
}
.frame(maxWidth:.infinity, maxHeight: .infinity)
}
var nameSection: some View {
Section(header: Text("Name")) {
TextField(LocalizedStringKey("Name"), text: $name)
}
}
}
I seem to have solved my problem with an init(). I created init(theme: Binding<GameTheme>) and then within the init assigned the theme via _theme = theme and then assigned the name via _name = State(initialValue: theme.name.wrappedValue).

Dismiss navigation view when Core Data object is deleted

I'm attempting to use SwiftUI and CoreData to build a macOS application. This application's main window has a NavigationView, with list items bound to a fetch request, and selecting any of these items populates the detail view. The navigation view goes kind of like this:
NavigationView {
VStack(spacing: 0) {
List(fetchRequest) { DetailRow(model: $0) }
.listStyle(SidebarListStyle())
HStack {
Button(action: add) { Text("+") }
Button(action: remove) { Text("-") }
}
}
Text("Select a model object")
}.navigationViewStyle(DoubleColumnNavigationViewStyle())
DetailRow is a NavigationLink that also defines the detail view:
NavigationLink(destination: ModelDetail(model: model)) {
Text(model.name)
}
I believe that the contents of ModelDetail isn't very important; either way, I'm fairly flexible with it.
In the navigation view, the "-" button, which calls the remove method, should delete the currently-selected model object and return to the default, empty detail view. Unfortunately, I'm struggling to come up with the right way to do this. I believe that I need the following interactions to happen:
subview communicates to navigation view which model object is currently selected
user clicks "-" button, navigation view's remove method deletes currently selected object
subview notices that its model object is being deleted
→ subview calls PresentationMode.dismiss()
Step 3 is the one I'm struggling with. Everything is working out alright so far without using view-model classes on top of the Core Data classes, but I feel stuck trying to figure out how to get the subview to call dismiss(). This needs to happen from the detail view, because it gets the PresentationMode from the environment, and the NavigationView changes it.
While I can get a Binding to the model's isDeleted property through #ObservedObject, I don't know how I can actually react to that change; Binding appears to use publishers under the hood, but they don't expose a publisher that I could hook up to with onPublish, for instance.
KVO over isDeleted might be possible, but listening from a value type isn't great; there's no good place to remove the observer, which could become problematic were the app to run for too long.
What's the guidance for this type of problem?
Heres my solution.
This is my NoteDetailView. It allows deletion from this view, or the "master" view in the Navigation hierarchy. This solution works on Mac, iPad, and iPhone.
I added an optional dateDeleted to my Entity. When a record is deleted, I simply add a value of Date() to this attribute and save the context. In my FetchRequests, I simply predicate for dateDeleted = nil. I'm going to add a trash can and stuff to my app later so people can view or permanently empty their trash.
Then I use a state variable and a notification to clear my View. You can change the code up for the functionality you want:
struct NoteDetailView: View {
var note: Note
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
#Environment(\.managedObjectContext) var managedObjectContext
#State var noteBody: String = ""
#State var showEditNoteView: Bool = false
#State var showEmptyView: Bool = false
init(note: Note) {
self.note = note
self._noteBody = State(initialValue: note.body)
}
var body: some View {
VStack {
if (!showEmptyView) {
Text("NOT DELETED")
}
else {
EmptyView()
}
}
.navigationBarTitle(!showEmptyView ? note.title : "")
.navigationBarItems(trailing:
HStack {
if (!showEmptyView) {
Button(action: {
self.showEditNoteView.toggle()
}, label: {
NavBarImage(image: "pencil")
})
.sheet(isPresented: $showEditNoteView, content: {
EditNoteView(note: self.note).environment(\.managedObjectContext, self.managedObjectContext)
})
}
}
)
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextDidSave)) { _ in
if (self.note.dateDeleted != nil) {
self.showEmptyView = true
self.presentationMode.wrappedValue.dismiss()
}
}
}
}