SwiftUI: Inline Editable Table
What are we making in this article?
An inline editable table that has the same behavior as the Finder App List View!
- Click to select a row
- Click on a specific field on the selected row to start editing.
- The
TextFieldshould fit the content. - When editing start, entire text is initially selected
- On Enter pressed, finish the editing, use the new value, and keep the selection (highlight) on the same row
- On ESC pressed, finish the editing, roll back to the previous value, and keep the selection (highlight) on the same row
- While editing, click on other rows should finish the editing AND switch the selection (highlight) to the clicked row.
A basic inline Editable Table is super simple! We can get it in 3 seconds!
However, as we try to fine tune the behaviors, there are so many holes to fall into! Or At least I fell into! Losing highlight on selection (still selected, but blue becomes gray) when unfocusing Textfield, fixedSize Textfield shaking all the time while entering, and blahhhhhh! (It might be hard to image what I am saying for right now, but we will see!)
Enough text! Let’s start!
Basic
Key here is to initialize the Table with a Binding!
import SwiftUI
private struct FolderStruct: Identifiable {
let id: UUID = UUID()
var name: String
var size: Int
}
extension FolderStruct {
static var sampleData: [FolderStruct] {
Array(1...10).map {
FolderStruct(name: "Folder \($0)", size: Int.random(in: 100...10000))
}
}
}
struct inlineEditableTable: View {
@State private var folders: [FolderStruct] = FolderStruct.sampleData
@State private var selections = Set<FolderStruct.ID>()
var body: some View {
Table(of: Binding<FolderStruct>.self, selection: $selections, columns: {
TableColumn("Name") { folder in
TextField(text: folder.name, label: {})
}
TableColumn("Size") { folder in
Text("\(folder.wrappedValue.size)")
}
}, rows: {
ForEach($folders) { folder in
TableRow(folder)
}
})
}
}I have used init(of:selection:columns:rows:) here, but if you don’t need to access the TableRows directly, you can also use init(_:content:) and passing $folders as the data, ie: Table($folders, selection: $selection) {…}.
Yes!
With just couple lines of code, it is already editable!
And!
The Enter key work just like expected!
Finishes the editing, uses the new value, and keeps the selection (highlight) on the same row!
We don’t even need to control the focus or the edibility of those text fields by ourselves!
However, the following behaviors are not met.
- The behavior on ESC pressed. The new value is reflected. We want to roll back to the old one.
- When editing start, no initial selection.
TextFieldtaking up entire column.- While editing, click on another row will only remove the focus on the
TextField, but NOT select the new row.
Some of these can be a little hard to recognize from the animation so give it a try yourself.
Honestly speaking, if you are fine with the behavior above, don’t even bother to read the rest! There are quite a bit of things we need to do!
However, I do think the those little points above do provide a better user experience! If you agree with me, let’s keep going!
Rolling Back on ESC
At first glance, two things we need to do here
- Save the previous value within some State variable when
TextFieldfocused - Override the existing ESC behavior by adding the
onExitCommand(perform:)modifier
ON FIRST GLANCE!
It does not work out as I expected and the place where it fails is totally out of my expectation!
Let’s give it a check to see what I meant!
struct inlineEditableTable: View {
@State private var folders: [FolderStruct] = FolderStruct.sampleData
@State private var selections = Set<FolderStruct.ID>()
@FocusState private var focusedRow: FolderStruct.ID?
@State private var previousValue: String?
var body: some View {
Table(of: Binding<FolderStruct>.self, selection: $selections, columns: {
TableColumn("Name") { folder in
TextField(text: folder.name, label: {})
.focused($focusedRow, equals: folder.id)
.onExitCommand {
focusedRow = nil
if let previousValue = self.previousValue {
folder.wrappedValue.name = previousValue
self.previousValue = nil
}
}
.onChange(of: focusedRow, {
guard focusedRow == folder.id else { return }
previousValue = folder.wrappedValue.name
})
}
TableColumn("Size") { folder in
Text("\(folder.wrappedValue.size)")
}
}, rows: {
ForEach($folders) { folder in
TableRow(folder)
}
})
}
}Note that when checking the focusedRow Binding with the Id of a folder, we don’t have to use the folder.wrappedValue.id.
And here is what we observe.
ESC button? Worked!
Value Roll back? Worked!
What is NOT working?
As soon as we press on the ESC button, or being more specific, as soon as we set focusedRow to nil, the entire selected row turns from blue to gray. It is still “highlighted”, but just in gray!
Selected Row? Losing focus
Why did I put a question mark here?
I originally thought it is just the row, but it ended up being the entire table!
I literally spent hours trying to figure this out! Stupid me!!!
Get Itsuki’s stories in your inbox
Join Medium for free to get updates from this writer.
But as soon as we know the reason, the solution is pretty simple! Keep the view focus to always be focused!
struct InlineEditableTable: View {
// ...
@FocusState var viewFocused: Bool
var body: some View {
Table(of: Binding<FolderStruct>.self, selection: $selections, columns: {
// ...
}, rows: {
// ...
})
.focused($viewFocused)
.onChange(of: viewFocused, {
viewFocused = true
})
}
}Here we go! Problem solved!
(If we print out viewFocused on change, we will see that it is set to false when we set focusedRow to nil!)
Initial Selected Contents
To control text selection of a TextField, we will be passing in a TextSelection binding.
So!
Does that mean all we have to do is to set this binding to select the entire text range when the TextField is focused, ie: focusedRow == folder.id, like following?
TextField("", text: folder.name, selection: $textSelection)
.focused($focusedRow, equals: folder.id)
.onChange(of: focusedRow, {
guard focusedRow == folder.id else { return }
let name = folder.wrappedValue.name
previousValue = name
guard let range = name.firstRange(of: name) else {return}
DispatchQueue.main.async {
textSelection = .init(range: range)
}
})Unfortunately nope!
This implementation will not set the selection, at least for 99% of the time I have tried. You can give it a try yourself!
Of course, you can add a delay to make this work, but then the behavior is a little different from what we like.
If we take a closer look at the Finder App, the text is actually select as soon as (or even before) it is focused.
Control TextField focus
We want to get notified before the Textfield is actually focused! Unfortunately, there is no such notification for this! There is the textDidBeginEditingNotification or the textDidChangeNotification, but obviously not what we need here.
So what we will be doing here is
- Adding an
overlayso that the user will not be able to interact with theTextFielddirectly at first. - Add a
TapGesture. If the row is selected and is indeed the current row, we will set theTextSelectionrange, and then focused the row. This will also allow the user to interact with theTextFielddirectly.
struct InlineEditableTable: View {
// ...
@FocusState private var focusedRow: FolderStruct.ID?
@FocusState var viewFocused: Bool
@State private var previousValue: String?
@State private var textSelection: TextSelection? = nil
var body: some View {
Table(of: Binding<FolderStruct>.self, selection: $selections, columns: {
TableColumn("Name") { folder in
TextField("", text: folder.name, selection: $textSelection)
// ...
.overlay(content: {
Color.clear
.contentShape(Rectangle())
.allowsHitTesting(focusedRow != folder.id)
.gesture(TapGesture()
.onEnded({
guard selections.count == 1, selections.contains(folder.id) else { return }
let name = folder.wrappedValue.name
previousValue = name
guard let range = name.firstRange(of: name) else {return}
textSelection = .init(range: range)
focusedRow = folder.id
}), isEnabled: selections.contains(folder.id) && selections.count == 1 && focusedRow != folder.id)
})
}
TableColumn("Size") { folder in
Text("\(folder.wrappedValue.size)")
}
}, rows: {
ForEach($folders) { folder in
TableRow(folder)
}
})
.focused($viewFocused)
.onChange(of: viewFocused, {
viewFocused = true
})
.onChange(of: self.focusedRow, {
if self.focusedRow == nil {
self.textSelection = nil
}
})
}
}one REALLY IMPORTANT point!
Make sure to set the TextSelection back to nil when focusedRow changed to nil.
This is really important because we are sharing the textSelection state variable for all TextFields. if we don’t reset it and we go from some longer text to shorter one, our app will, sometimes, not all, like 1 out of 20 times, crash due to string index out of range.
If you are interested in some more advanced text selections, please check out one of my previous articles SwiftUI: Mastering Text Selection, where we have checked out on how we can process, control, enable, and disable selections for TextField, TextEditor, Text (as Label)!
TextField Fits Content
I really want to say
This part is simple!
fixedSize()is all we need!
TextField("", text: folder.name, selection: $textSelection)
.focused($focusedRow, equals: folder.id)
.fixedSize()NOOOO!
It might be pretty subtle, but as we enter into the TextField, all the text are shaking!
One way to solve this is to add the TextField as an overlay to a Text with some padding so that the TextField have enough space to add more text without trying to squeeze it in first, which is the cause of the shake (I think…).
Text(folder.wrappedValue.name)
.foregroundStyle(.clear)
.padding(.leading, 8)
.overlay(content: {
TextField("", text: folder.name, selection: $textSelection)
.focused($focusedRow, equals: folder.id)
// ...
})I know that there is a little additional trailing padding, but I personally think that this is better than having the text shaking while entering.
I have also tried to create some custom TextFieldStyle to see if I can solve the shaking problem but no success!
Click On Other Rows While Editing
While editing, click on other rows should finish the editing AND switch the selection (highlight) to the clicked row.
Right now, it will only remove the focus of the current editing row, but NOT select the new row.
To solve this problem, we will be adding a simultaneousGesture that is only enabled while editing to set the focusedRow to nil and the selections to be the new folder.id.
struct InlineEditableTable: View {
// ...
var body: some View {
Table(of: Binding<FolderStruct>.self, selection: $selections, columns: {
TableColumn("Name") { folder in
Text(folder.wrappedValue.name)
// ...
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.simultaneousGesture(
TapGesture()
.onEnded({
focusedRow = nil
selections = [folder.id]
}), isEnabled: focusedRow != nil)
}
TableColumn("Size") { folder in
Text("\(folder.wrappedValue.size)")
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.simultaneousGesture(
TapGesture()
.onEnded({
focusedRow = nil
selections = [folder.id]
}), isEnabled: focusedRow != nil)
}
}, rows: {
// ...
})
// ...
}
}
Couple Notes here!
- We want the entire column to trigger the gesture so we have to have the
frame(maxWidth: .infinity, alignment: .leading)because theTextwill only have the width fitting the content, and thecontentShapebecause the column have a clear background - We need to add the
simultaneousGestureto ALL Columns. I really hope we can attach the gestures directly toTableRow, but unfortunately, this is not allowed. - Only enabled the gesture if
focusedRowis notnil. Why cannot we just have it always betrueand manage the selection by ourselves? If you have single selection, it is actually pretty doable, however, when we enable multi-selection, we will have to watch for the command key and whatever and whatever! Too much work, not worth it! - In order for the
simultaneousGestureto work properly, we have to have theviewFocusedbinding above. Otherwise, we will observe (again) when setting thefocusedRowtonil, selected row are “highlighted” with gray, indicating that the view is not in focus.
Final Code
We finally get all the behaviors we wanted!
This is the final code for you to give it a try yourself!
import SwiftUI
private struct FolderStruct: Identifiable {
let id: UUID = UUID()
var name: String
var size: Int
}
extension FolderStruct {
static var sampleData: [FolderStruct] {
Array(1...10).map {
FolderStruct(name: "Folder \($0)", size: Int.random(in: 100...10000))
}
}
}
struct InlineEditableTable: View {
@State private var folders: [FolderStruct] = FolderStruct.sampleData
@State private var selections = Set<FolderStruct.ID>()
@FocusState private var focusedRow: FolderStruct.ID?
@FocusState var viewFocused: Bool
@State private var previousValue: String?
@State private var textSelection: TextSelection? = nil
var body: some View {
Table($folders, columns: {
TableColumn("Name") { folder in
Text("")
}})
Table(of: Binding<FolderStruct>.self, selection: $selections, columns: {
TableColumn("Name") { folder in
Text(folder.wrappedValue.name)
.foregroundStyle(.clear)
.padding(.leading, 8)
.overlay(content: {
TextField("", text: folder.name, selection: $textSelection)
.focused($focusedRow, equals: folder.id)
.onExitCommand {
focusedRow = nil
if let previousValue = self.previousValue {
folder.wrappedValue.name = previousValue
self.previousValue = nil
}
}
.onChange(of: focusedRow, {
guard focusedRow == folder.id else { return }
previousValue = folder.wrappedValue.name
})
.overlay(content: {
Color.clear
.contentShape(Rectangle())
.allowsHitTesting(focusedRow != folder.id)
.gesture(TapGesture()
.onEnded({
guard selections.count == 1, selections.contains(folder.id) else { return }
let name = folder.wrappedValue.name
previousValue = name
guard let range = name.firstRange(of: name) else {return}
textSelection = .init(range: range)
focusedRow = folder.id
}), isEnabled: selections.contains(folder.id) && selections.count == 1 && focusedRow != folder.id)
})
})
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.simultaneousGesture(
TapGesture()
.onEnded({
focusedRow = nil
selections = [folder.id]
}), isEnabled: focusedRow != nil)
}
TableColumn("Size") { folder in
Text("\(folder.wrappedValue.size)")
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.simultaneousGesture(
TapGesture()
.onEnded({
focusedRow = nil
selections = [folder.id]
}), isEnabled: focusedRow != nil)
}
}, rows: {
ForEach($folders) { folder in
TableRow(folder)
}
})
.focused($viewFocused)
.onChange(of: viewFocused, {
viewFocused = true
})
.onChange(of: self.focusedRow, {
if self.focusedRow == nil {
self.textSelection = nil
}
})
}
}
Thank you for reading!
That’s it for this article!
I know, for pretty much everything we want to achieve, there are some kinds of holes for us to fall into!
Of course, there are a lot of little things you can do to make what we have above a little easier to use. For example, you can factor out the simultaneousGesture to a view modifier, put the TextField overlay to an entire different view, and many more!
I will leave it out to you!
Happy inline-editing!










