Created October 2021.

Overview

At WWDC 2019, Apple surprised app developers worldwide by announcing a completely new development framework called SwiftUI. Since that defining moment, SwiftUI and its older sibling Swift, have pushed the app development envelope to a new level. SwiftUI enables user interface design to be coded using a compositional methodology resulting in a clean coding approach that is easy to understand and modify. Both Swift and SwiftUI are truly great modern programming languages! And, Xcode makes them sing!

Alas, developing code for real-world apps is hard and still a humbling experience. Lists are a common UI element in many apps. This post describes the SwiftUI and Swift code to implement a favorites list with rows consisting of two editable text fields: type of favorite and the favorite as shown below.

This example will have a vertically scrollable list with each row consisting of multiple editable text fields per row (example uses 2 text fields/row) and use previous/next buttons to propagate the cursor focus through the list’s text fields. In addition, a row can be deleted from the list or moved to a new location within the list by dragging the row. Rows are automatically scrolled into view as the focus is propagated using the previous/next buttons.

SwiftUI in iOS15 brings two additional features to list that make coding this example much cleaner: bindable lists and the focus state property wrapper.

Data Model

To implement the new FocusState property wrapper when the order of rows in the list may change or the number of rows may change, we must use a unique fixed identifier (UUID) to identify each row. Use of enums or logical comparisons to a row index won’t work here since the list may be reordered and number of rows may change. Since the FocusState is used to identify which text field gets focus, each text field will need its own UUID. Since there are two text fields per row for this example, the Fave struct consists of two UUIDs and two strings.

struct Fave: Identifiable, Hashable, Equatable {
    var id: UUID
    var category: String
    var favoriteID: UUID
    var favorite: String
    
    init() {
        id = UUID.init()
        category = ""
        favoriteID = UUID.init()
        favorite = ""
    }
    
    init(id: UUID, category: String, favoriteID: UUID, favorite: String) {
        self.id = id
        self.category = category
        self.favoriteID = favoriteID
        self.favorite = favorite
    }
}

In the DataModel class, a published array of Fave structs holds the initial list with pre-populated values. The two functions for moving a row or deleting a row from a list are located in this data model.

class DataModel: ObservableObject {

    @Published var faves: [Fave] = [
        Fave(id: UUID(uuidString: "0A9E9653-EECD-40F4-8A84-098FB6B3D748")!, category: "vehicle" , favoriteID: UUID(uuidString: "6AB85272-BDFE-43D7-861F-C909645185FA")!, favorite: "67 Mustang Shelby"),
        Fave(id: UUID(uuidString: "60CF8770-7F9D-4FC5-AED3-24486B8A5884")!, category: "vacation", favoriteID: UUID(uuidString: "68011499-76D5-4C0D-BD84-A4AE59E280AD")!, favorite: "summer in Lugano" ),
        Fave(id: UUID(uuidString: "97B2E421-B5F4-4F63-B660-0D9D557E6EDD")!, category: "laptop"  , favoriteID: UUID(uuidString: "CD8F983A-0E09-4A11-9670-E4BBAA8151C6")!, favorite: "MacBook Pro"      ),
        Fave(id: UUID(uuidString: "30EE6385-0D18-44DE-A50D-D0DE3F8494A3")!, category: "bicycle" , favoriteID: UUID(uuidString: "93D25C54-1060-478C-930D-055C67C2B22A")!, favorite: "Tommaso"          ),
        Fave(id: UUID(uuidString: "201672F5-0C69-4191-96AE-3538017A9560")!, category: "food"    , favoriteID: UUID(uuidString: "EAF42D25-564A-4C68-BBC0-9F1E06E8752A")!, favorite: "Salmon"           ),
        Fave(id: UUID(uuidString: "95DC21AF-7499-4FAF-AEDF-CDFEC95E6CC8")!, category: "dessert" , favoriteID: UUID(uuidString: "CEF82743-DDE4-40D7-97E0-64CE4019395B")!, favorite: "Lemon pie"        ),
        Fave(id: UUID(uuidString: "906EFF74-7869-4F87-ACDB-7A91512AF7B8")!, category: "movies"  , favoriteID: UUID(uuidString: "28BE42F2-0ED1-4027-A757-B4D4E43311A1")!, favorite: "Star Wars"        ),
        Fave(id: UUID(uuidString: "01145D68-316D-4ECF-985F-B5EE5CD1A937")!, category: "TV show" , favoriteID: UUID(uuidString: "E3991215-862B-43A2-828D-24EFB00DD746")!, favorite: "Game of Thrones"  ),
        Fave(id: UUID(uuidString: "E71AC9C3-669C-4931-BC4A-6101240A9873")!, category: "Code"    , favoriteID: UUID(uuidString: "840E4C09-E578-4B11-BE35-3D0B61075116")!, favorite: "Swift, SwiftUI"   ),
        Fave(id: UUID(uuidString: "FB39164D-003A-4D40-815D-F24861EB8F94")!, category: "Color"   , favoriteID: UUID(uuidString: "12B89178-6F5D-44EB-8929-FB6F09C6FC74")!, favorite: "Blue"             )
    ]
    
    func relocate(from source: IndexSet, to destination: Int) {
        DispatchQueue.main.async {
            self.faves.move(fromOffsets: source, toOffset: destination)
        }
    }
    
    func delete(from source: IndexSet) {
        DispatchQueue.main.async {
            self.faves.remove(atOffsets: source)
        }
    }
    
}

List15ProjectApp

In the List15ProjectApp.swift file, the DataModel class is added as an environment object. In addition, code is added to hide the distracting autolayout warnings in the Xcode debug viewer that are generated when an iPad is used as the development device.

@main
struct List15ProjectApp: App {
    
    @StateObject private var dataModel = DataModel()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(dataModel)
                .onAppear(perform: {
                    // hack to disable iPad autolayout warnings when text fields are being used
                    UserDefaults.standard.setValue(false, forKey: "_UIConstraintBasedLayoutLogUnsatisfiable")
                })
        }
    }
}

FaveRowView

Ultimately, the code for each row was refactored into a separate file and encapsulated into its own view struct. The FaveRowView contains two TextField views, each with a focused modifier. When the UUID from the FocusState property wrapper, textFieldFocus, matches the FaveStruct UUID associated with either the first or second TextField, then that TextField will become active showing a blue blinking cursor. Note that textFieldFocus requires a FocusState binding instead of the simpler Binding property wrapper. This method can be extended to additional TextFields per row without significant software development impact.

struct FaveRowView: View {
    
    @Binding var row: Fave
    var textFieldFocus: FocusState<UUID?>.Binding

    var body: some View {
        HStack {
            TextField("", text: $row.category, prompt: Text("Enter a category"))
                .focused(textFieldFocus, equals: row.id)
                .font(.subheadline)
                .autocapitalization(.none)
                .disableAutocorrection(true)
                .foregroundColor(.gray)
                .padding(7)
                .background(Color(UIColor.systemGray4))
                .cornerRadius(8)
                .frame(width: 80)

            TextField("", text: $row.favorite, prompt: Text("Enter a fave"))
                .focused(textFieldFocus, equals: row.favoriteID)
                .font(.subheadline)
                .autocapitalization(.none)
                .disableAutocorrection(true)
                .foregroundColor(.white)
                .padding(7)
                .background(Color(UIColor.systemGray4))
                .cornerRadius(8)
        }
    }
}

ContentView

There are two major sections of code in this file: List, and ToolbarItemGroup.

The code for the List section follows a commonly used architecture with ScrollViewReader, VStack, List, and ForEach. Note the use of the iOS15 bindable list ($dataModel.faves and $singleFave on the ForEach line ) and the .listRowSeparatorTint modifier.

For this example app, editMode is enabled when this view appears and remains enabled. I do this in the example app because edit mode is tougher code to get right with its move, delete, edit, and FocusState operations.

struct ContentView: View {
    
    @EnvironmentObject var dataModel: DataModel
    @Environment(\.editMode) var editMode
    
    @FocusState private var focus: UUID?
        
    @State private var focusPrevious:  UUID = UUID.init()
    @State private var scrollPrevious: UUID = UUID.init()
    @State private var focusNext:      UUID = UUID.init()
    @State private var scrollNext:     UUID = UUID.init()
        
    var body: some View {
        ScrollViewReader { proxy in
            VStack {
                List {
                    ForEach($dataModel.faves) {$singleFave in
                        FaveRowView(row: $singleFave, textFieldFocus: $focus)
                            .listRowSeparatorTint(.yellow.opacity(0.4))
                            .padding(.vertical, 10)
                    }
                    .onMove(perform: dataModel.relocate)
                    .onDelete(perform: dataModel.delete)
                }
                .listStyle(.insetGrouped)
            }
            .toolbar { ... }
        }
        .frame(maxHeight: .infinity)
        .frame(width: 400, alignment: .center)
        .onAppear {
            editMode?.wrappedValue = .active
        }
    }

}

The buttons with their code to propagate the cursor and focus to either the previous TextField or the next TextField have been placed in a toolbar located on the top row of the screen keyboard.

.toolbar {
    ToolbarItemGroup(placement: .keyboard) {
        Spacer()

        Button(action: {
            if let currentFavesIndex: Int = dataModel.faves.firstIndex(where: { $0.id == focus }) { // found match on first text field in row
                withAnimation(.easeInOut(duration: 0.3)) {
                    proxy.scrollTo(dataModel.faves[max(currentFavesIndex - 2, 0)].id)
                }
                let previousFave = dataModel.faves[max(currentFavesIndex - 1, 0)]
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                    focus = (currentFavesIndex > 0) ? previousFave.favoriteID : previousFave.id
                }
            } else if let currentFavesIndex: Int = dataModel.faves.firstIndex(where: { $0.favoriteID == focus }) { // found match on second text field in row
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                    focus = dataModel.faves[currentFavesIndex].id
                }
            }
        }) {
            Text("Previous")
                .padding(.horizontal, 5)
        }
        .tint(Color(UIColor.systemGray3))
        .buttonStyle(.borderedProminent)
        .buttonBorderShape(.roundedRectangle)
        .controlSize(.small)

        Button(action: {
            if let currentFavesIndex: Int = dataModel.faves.firstIndex(where: { $0.id == focus }) { // found match on first text field in row
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                    focus = dataModel.faves[currentFavesIndex].favoriteID
                }
            } else if let currentFavesIndex: Int = dataModel.faves.firstIndex(where: { $0.favoriteID == focus }) { // found match on second text field in row
                withAnimation(.easeInOut(duration: 0.3)) {
                    proxy.scrollTo(dataModel.faves[min(currentFavesIndex + 2, dataModel.faves.count - 1)].id)
                }
                let nextFave = dataModel.faves[ min(currentFavesIndex + 1, dataModel.faves.count - 1) ]
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                    focus = (currentFavesIndex < dataModel.faves.count - 1) ? nextFave.id : nextFave.favoriteID
                }
            }
        }) {
            Text("Next")
                .padding(.horizontal, 5)
        }
        .tint(Color(UIColor.systemGray3))
        .buttonStyle(.borderedProminent)
        .buttonBorderShape(.roundedRectangle)
        .controlSize(.small)
        .padding(.horizontal, 20)
    }
    

Only the processing logic of the Next button is explained, since the processing logic for the Previous button is the mirror image to that of the Next button.

After the Next button is touched, the first step is to determine which text field is currently active (first or second text field) and its row index.

If the cursor is currently on the first TextField in a row, then:

  • Set the focus to the second TextField on the same row.

Else, if the cursor is currently on the second TextField in a row, then:

  • Scroll the list up so that the row beyond the next row (or the last row) will be visible. This extra row brings the row being edited fully into view.
  • Set the focus on the first TextField in the next row unless it is already at the last row. In that case, keep focus on the second TextField of the last row.

Stepping the scroll is animated with a duration of 0.3 seconds. Setting the new focus is delayed until after the scroll animation.

Testing

Part of the difficulty in developing code with a user interface is making sure the user interface animates smoothly, behaves predictably, displays consistently. The need to test app code on actual devices can not be overstated. That is why the index for scroll offset is set to two, and the Previous/Next buttons are added to the keyboard accessory view. Testing for edge cases is the other gold nugget. For this app, tested with an empty array, a 1-element array, and a 2-element array.

Summary

The additional features in Swift and SwiftUI for iOS15 bring nearly all the remaining pieces for building full-featured lists. This app project is a good opportunity to explore use of the FocusState property wrapper. I was pleasantly surprised at the simplicity of code in the end. To expand beyond two TextFields in a row is straight forward. Adding Up and Down buttons to the propagate focus up and down within a TextField column is also straight forward.

The Xcode project code for this example is located on GitHub ( List15Project ).

Reference Material

How to scroll to a specific row in a list by Paul Hudson (Hacking with Swift)

How to create a list of dynamic items by Paul Hudson (Hacking with Swift)

How to create a List or a ForEach from a binding by Paul Hudson (Hacking with Swift)

How to dismiss the keyboard for a TextField by Paul Hudson (Hacking with Swift)

My ToDos SwiftUI app iOS15 update 3: Swipe Actions and Bound Lists YouTube video by Stewart Lynch (CreaTECH Solutions)

Bindable SwiftUI list elements by John Sundell (Swift by Sundell)

Using KeyPaths to Drive Programmatic Focus by Matthaus Woolard (LostMoa)

Mastering FocusState property wrapper in SwiftUI by Majid Jabrayilov (Swift with Majid)

Credits

The Xcode logo is a copyright and trademark of Apple.