SwiftUI: Editable TextFields in Lists - swift

*** 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.

Related

SwiftUI List on macOS: highlighting content in the selected row?

In a SwiftUI List, when a row is selected, a blue selection is drawn in the selection, and foreground Text with the default primary color is automatically made white. For other views with custom colors, I'd like to be able to make sure they become tinted white to match the system apps. For example, see the blue dot on the selected row:
Example code:
List(selection: $selection) {
ForEach(0..<4) { index in
HStack {
Image(systemName: "circle.fill")
.foregroundColor(.blue)
Text("Test")
}
.tag(index)
}
}
Any tips on how to achieve the correct result here? 🙏
With NSTableCellView, there is the backgroundStyle property, but I couldn't find anything like this.
I've searched all of the available environment variables, and couldn't find anything appropriate.
I have also tried manually including an isSelected binding on a view for each row, but the selection binding is not updated by List until mouse-up, while the highlight is updated on mouse-down and drag, so this results in a flickery appearance and is not right either.
I'm also looking to customize not just a single SF Symbol image, but also a RoundedRectangle that is drawn with a custom foreground color that I want to become white upon selection.
Approach
Use a Label for the cell
Code
struct ContentView: View {
#State private var selection: Int?
var body: some View {
List(selection: $selection) {
ForEach(0..<4) { index in
Label("Test", systemImage: "circle.fill")
.tag(index)
}
}
}
}
Screenshot

SwiftUI: popover + sheet in different hierarchies problem

I experience a problem of presenting a popover and then trying to present a sheet. The sheet is unable to be presented.
I have prepared a short code that displays two buttons
The first one presents a popover over itself ("Click this button")
The second one presents a sheet ("Then this button")
Steps to reproduce
--- Reproducible on an iPad ---
Click the first button, a popover is presented
Directly click the second button while the popover is being visible.
(without dismissing the popover any other way)
State: The popover is dismissed, but the sheet is not being presented. And it is impossible to present it using the second button. The popover button still works though.
Error
The following message is being printed to the console:
[Presentation] Attempt to present <_TtGC7SwiftUI29PresentationHostingControllerVS_7AnyView_: 0x10bc13cf0>
on <_TtGC7SwiftUI19UIHostingControllerV10AppBuilder8RootView_: 0x105a093f0>
(from <_TtGC7SwiftUI19UIHostingControllerV10AppBuilder8RootView_: 0x105a093f0>)
which is already presenting <_TtGC7SwiftUI29PresentationHostingControllerVS_7AnyView_: 0x10ba170a0>.
Code
import SwiftUI
struct MyView: View {
#State var showSheet: Bool = false
struct SomeDeepView: View {
#State var showPopover: Bool = false
var body: some View {
Button {
showPopover = true
} label: {
Text("Click this button")
}
.popover(isPresented: $showPopover) {
Text("Popover content")
}
}
}
var body: some View {
VStack(spacing: 64) {
SomeDeepView()
Button {
showSheet = true
} label: {
Text("Then this button")
}
}
.sheet(isPresented: $showSheet) {
Text("Sheet content")
}
.frame(width: 500, height: 500, alignment: .center)
}
}
My thoughts
MyView shoudn't care about the internal stuff of SomeDeepView.
Also, SomeDeepView shouldn't care much about its exterior/parents.
Yet, we can't show a popover and a sheet at the same time. I would accept this knowing that the framework would handle this and wouldn't break. However, it does break.
Unexpected side effect: by changing showSheet is not able to display the sheet anymore.
Any thoughts, ideas are very welcome.
Thank you
Edit1: I don't consider toggle() as an effective sulution as it introduces another bug. You would need to press the button multiple times before it would react.
If you change showSheet = true to showSheet.toggle(), the sheet will show up on the 3rd time passing the 2nd button.
Other than that I guess you would have to manually check, that the sheet can't be opened while the popover is open...

SwiftUI Editable NavigationTitle

Is it possible to make the navigation title of SwiftUI editable?
Unfortunately the navigationTitle modifier only accepts Text views and not TextField views.
I want to do this instead of just using a text field below the navigation bar because I still want the nice default behaviour of having the modified title appear in the navigation bar inline when the user scrolls down and the navigation bar allocates space for the navigation title whether you define one or not.
iOS/iPadOS 16+, macOS 13+
The navigationTitle modifier now accepts a Binding<String> argument, as well as the more usual String-based initializer.
When using a bound value and the navigation bar is in its inline form, the title gains a drop-down menu with a Rename option. Tapping this allows the user to edit the view's title:
struct EditableTitleView: View {
#State private var title = "View Title"
var body: some View {
Text("Editable Title View")
.navigationTitle($title)
.navigationBarTitleDisplayMode(.inline)
}
}
This isn't exactly the same UX as always using a text field, but as it's a standard SwiftUI implementation it's a lot easier to implement.
For earlier versions of iOS
You can place a custom view in your NavigationView at the position where the title might be expected to go by specifying a ToolbarItem with a placement value of .principal, for example in the code below. I've added the RoundedBorderTextFieldStyle to make the text field more visible:
struct EditableTitleView: View {
#State private var editableTitle: String = "My Title"
var body: some View {
NavigationView {
Text("View with editable title")
.toolbar {
ToolbarItem(placement: .principal) {
TextField("Title", text: $editableTitle)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
}
}
}
}
Note that if you also add a navigationTitle modifier into your view, its default large style on iOS will still display beneath the toolbar, but if it scrolls off the page it while disappear while your principal item will remain on screen. If you set .navigationBarTitleDisplayMode(.inline) then the larger style title will never display.
I mention this because you should consider keeping a title for your view anyway, for a couple of reasons:
If a NavigationLink pushes a view on the stack, you want a meaningful name to appear in the back button and the list of stacked views that appear on long press.
I haven't checked what happens with VoiceOver when you have a navigation subview without a title. The more you override native behaviour, the more you need to consider whether you are making your app less accessible than the SwiftUI defaults provide.
Try a TextField inside of a ToolbarItem in the principal slot of the toolbar. Create a computed property for the destination and give it an editable navigation title, too.
struct TextFieldNavigationTitleView: View {
#State var mainTitle = "Main Menu"
#State var areaOneTitle = "Area One"
var body: some View {
NavigationView {
NavigationLink("App Area One", destination: areaOne)
.toolbar {
ToolbarItem(placement: .principal) {
TextField("Navigation Title", text: $mainTitle)
}
}
}
}
var areaOne : some View {
Text("AREA ONE")
.toolbar {
ToolbarItem(placement: .principal) {
TextField("Area One Title", text: $areaOneTitle)
}
}
}
}

SwiftUI List disclosure indicator without NavigationLink

I am searching for a solution to show the disclosure indicator chevron without having the need to wrap my view into an NavigationLink. For example I want to show the indicator but not navigate to a new view but instead show a modal for example.
I have found a lot solutions that hide the indicator button but none which explains how to add one. Is this even possible in the current SwiftUI version ?
struct MyList: View {
var body: some View {
NavigationView {
List {
Section {
Text("Item 1")
Text("Item 2")
Text("Item 3")
Text("Item 4")
}
}
}
}
For example I want to add the disclosure indicator to Item 1 without needing to wrap it into an NavigationLink
I already tried to fake the indicator with the chevron.right SF Symbol, but the symbol does not match 100% the default iOS one. Top is default bottom is chevron.right.
It is definitely possible.
You can use a combination of Button and a non-functional NavigationLink to achieve what you want.
Add the following extension on NavigationLink.
extension NavigationLink where Label == EmptyView, Destination == EmptyView {
/// Useful in cases where a `NavigationLink` is needed but there should not be
/// a destination. e.g. for programmatic navigation.
static var empty: NavigationLink {
self.init(destination: EmptyView(), label: { EmptyView() })
}
}
Then, in your List, you can do something like this for the row:
// ...
ForEach(section.items) { item in
Button(action: {
// your custom navigation / action goes here
}) {
HStack {
Text(item.name)
Spacer()
NavigationLink.empty
}
}
}
// ...
The above produces the same result as if you had used a NavigationLink and also highlights / dehighlights the row as expected on interactions.
Hopefully, this is what you are looking for. You can add the item to a HStack and with a Spacer in between fake it that its a Link:
HStack {
Text("Item 1")
Spacer()
Button(action: {
}){
Image(systemName: "chevron.right")
.font(.body)
}
}
The answers already submitted don't account for one thing: the highlighting of the cell when it is tapped. See the About Peek-a-View cell in the image at the bottom of my answer — it is being highlighted because I was pressing it when the screenshot was taken.
My solution accounts for both this and the chevron:
Button(action: { /* handle the tap here */ }) {
NavigationLink("Cell title", destination: EmptyView())
}
.foregroundColor(Color(uiColor: .label))
The presence of the Button seems to inform SwiftUI when the cell is being tapped; simply adding an onTapGesture() is not enough.
The only downside to this approach is that specifying the .foregroundColor() is required; without it, the button text will be blue instead.
in iOS15 the following is a better match as the other solutions were little too big and not bold enough. it'll also resize better to different Display scales better than specifying font sizes.
HStack {
Text("Label")
Spacer()
Image(systemName: "chevron.forward")
.font(Font.system(.caption).weight(.bold))
.foregroundColor(Color(UIColor.tertiaryLabel))
}
Would be good if there was an offical way of doing this. Updating every OS tweak is annoying.
I found an original looking solution. Inserting the icon by hand does not bring the exact same look.
The trick is to use the initializer with the "isActive" parameter and pass a local binding which is always false. So the NavigationLink waits for a programmatically trigger event which will never occur.
// use this initializer
NavigationLink(isActive: <Binding<Bool>>, destination: <() -> _>, label: <() -> _>)
You can pass an empty closure to the destination parameter. It will never get called anyway. To do some action you put a button on top within a ZStack.
func navigationLinkStyle() -> some View {
let never = Binding<Bool> { false } set: { _ in }
return ZStack {
NavigationLink(isActive: never, destination: { }) {
Text("Item 1") // your list cell view
}
Button {
// do your action on tap gesture
} label: {
EmptyView() // invisible placeholder
}
}
}
For accessibility you might need to mimic UIKit version of disclosure indicator. You don't need to implement it this way per se but if you use e.g. Appium for testing you might want to have it like this to keep tests succeeding
Apparently UIKit's disclosure indicator is a disabled button with some accessibility values so here's the solution:
struct DisclosureIndicator: View {
var body: some View {
Button {
} label: {
Image(systemName: "chevron.right")
.font(.body)
.foregroundColor(Color(UIColor.tertiaryLabel))
}
.disabled(true)
.accessibilityLabel(Text("chevron"))
.accessibilityIdentifier("chevron")
.accessibilityHidden(true)
}
}
Or maybe create a fake one and use it, even if you tap you can call your events.
NavigationLink(destination: EmptyView()) {
HStack {
Circle()
Text("TITLE")
}
}
.contentShape(Rectangle())
.onTapGesture {
print("ALERT MAYBE")
}
I created a custom NavigationLink that:
Adds an action API (instead of having to push a View)
Shows the disclosure indicator
Ensures that List cell selection remains as-is
Usage
MYNavigationLink(action: {
didSelectCell()
}) {
MYCellView()
}
Code
import SwiftUI
struct MYNavigationLink<Label: View>: View {
#Environment(\.colorScheme) var colorScheme
private let action: () -> Void
private let label: () -> Label
init(action: #escaping () -> Void, #ViewBuilder label: #escaping () -> Label) {
self.action = action
self.label = label
}
var body: some View {
Button(action: action) {
HStack(spacing: 0) {
label()
Spacer()
NavigationLink.empty
.layoutPriority(-1) // prioritize `label`
}
}
// Fix the `tint` color that `Button` adds
.tint(colorScheme == .dark ? .white : .black) // TODO: Change this for your app
}
}
// Inspiration:
// - https://stackoverflow.com/a/66891173/826435
private extension NavigationLink where Label == EmptyView, Destination == EmptyView {
static var empty: NavigationLink {
self.init(destination: EmptyView(), label: { EmptyView() })
}
}

Strange behavior of SwiftUI TextField (MacOS)

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