How to manage SwiftUI Focus State in iOS14 and before?
In most cases, when we're talking about focus state, we actually mean activating or deactivating the keyboard for input fields. This article will be focused specifically on these use cases.
In iOS15 we have the @FocusState
property wrapper which makes handling focus states rather simple. Here is an article on this topic: How to manage Focus State using @FocusState in SwiftUI
In order to manage a focus state in iOS13 and iOS14, we have to use our old friends from UIKit
- UITextField
and UITextView
which is also not so complicated but requires some boilerplates code to be implemented.
Setting requirements
First, let's set the requirements for the focus state implementation:
- Set the initial focus state. Should any of the fields be focused when the view appears.
- Ability to deactivate focus state. For example, the user wants to open a calendar picker when the text field is in focus. We should hide the keyboard and present him a calendar picker.
- Ability to restore focus state. When the user has finished picking the date, we should restore the focus state.
Adding control properties
First, let's add some properties that will be used to control our focus state. For our requirements these could be:
- A flag to activate / deactivate focus state
- A property to store the field we want to be in focus
For a sample implementation let's use a screen for adding new tasks with two fields, for the title, and for notes:
final class AddNewTaskViewModel: ObservableObject {
enum Field {
case title, note
}
@Published var isInFocus: Bool = true // focus is active by default
@Published var focusedField: Field? = .title // default focused field
...
}
Bringing UIKit first responder management to SwiftUI
In UIKit
we have becomeFirstResponder()
and resignFirstResponder()
methods to handle the focus state.
Let's bring them into SwiftUI
by creating a UITextField
representation. To do so, we should create a UIViewRepresentable
struct
and implement two protocol methods for creating and updating the view:
This View
will receive the actual focus state (isFirstResponder
) and the currently focused field (focusedField
):
struct NewTaskUIKitTextField: UIViewRepresentable {
let fieldKind: AddNewTaskViewModel.Field
@Binding var text: String
@Binding var focusedField: AddNewTaskViewModel.Field?
var isFirstResponder: Bool = false // false by default
func makeUIView(context: UIViewRepresentableContext<NewTaskUIKitTextField>) -> UITextField {
let textField = UITextField(frame: .zero)
textField.delegate = context.coordinator
return textField
}
func updateUIView(_ uiView: UITextField, context: UIViewRepresentableContext<NewTaskUIKitTextField>) {
uiView.text = text
// Manage focus state using coordinator
// We'll implement this later
}
}
We pass to NewTaskUIKitTextField
its kind (fieldKind
), our screen current focused field (focusedField
), and whether the text field is in focus state (isFirstResponder
).
Next, we should add a Coordinator
to communicate the UITextField
changes using UITextFieldDelegate
methods:
extension NewTaskUIKitTextField {
class Coordinator: NSObject, UITextFieldDelegate {
let fieldKind: AddNewTaskViewModel.Field
@Binding var text: String
var didBecomeFirstResponder: Bool? = nil
var didResignFirstResponder: Bool? = nil
@Binding var focusedField: AddNewTaskViewModel.Field?
init(fieldKind: AddNewTaskViewModel.Field, text: Binding<String>, focusedField: Binding<AddNewTaskViewModel.Field?>) {
self.fieldKind = fieldKind
_text = text
_focusedField = focusedField
}
func textFieldDidChangeSelection(_ textField: UITextField) {
text = textField.text ?? ""
}
func textFieldDidBeginEditing(_ textField: UITextField) {
focusedField = fieldKind
}
}
}
The next step is to start using this coordinator by implementing the UIViewRepresentable
protocol method makeCoordinator
:
struct NewTaskUIKitTextField: UIViewRepresentable {
...
func makeCoordinator() -> NewTaskUIKitTextField.Coordinator {
return Coordinator(fieldKind: fieldKind, text: $text, focusedField: $focusedField)
}
}
Finally, we can now use our coordinator in the updateUIView
method to actually manage the focus state:
struct NewTaskUIKitTextField: UIViewRepresentable {
...
func updateUIView(_ uiView: UITextField, context: UIViewRepresentableContext<NewTaskUIKitTextField>) {
uiView.text = text
// Manage focus state using coordinator
if isFirstResponder && context.coordinator.didBecomeFirstResponder != true {
uiView.becomeFirstResponder()
context.coordinator.didBecomeFirstResponder = true
context.coordinator.didResignFirstResponder = false
} else if !isFirstResponder && context.coordinator.didResignFirstResponder != true {
uiView.resignFirstResponder()
context.coordinator.didResignFirstResponder = true
context.coordinator.didBecomeFirstResponder = false
}
}
}
Here we compare the focus state we want (isFirstResponder
) with the actual state that we hold in didBecomeFirstResponder
and didResignFirstResponder
properties of the coordinator. If the condition is met, we call either becomeFirstResponer
or resignFirstResponder
and save in properties flags for future reference.
That is all we need to be able to manage the focus state on the screen for multiple input fields.
Adding View to the screen
When we add the NewTaskUIKitTextField
to the screen, we provide it the following parameters:
text
- bind it with the view model's property for the text valuefocusedField
- bind with the view model's property that where we hold the last focused field. The default value we set in the view model will be used for the initial load and then it will be changed automatically if the user selects one of our input fields.isFirstResponder
- tell the field whether it should be focused or not. Remember that we also have a flagisInFocus
in the view model? This flag is a convenient way to hide the keyboard on the screen with the ability to restore it for the last focused field later.
Here is an example:
struct AddNewTaskViewIOS14: View {
@ObservedObject var viewModel: AddNewTaskViewModel
var body: some View {
VStack {
NewTaskUIKitTextField(
text: $viewModel.title,
focusedField: $viewModel.focusedField,
isFirstResponder: (viewModel.isInFocus && viewModel.focusedField == .title) ? true : false,
)
.frame(height: 30) // is needed to correctly size the UIKit TextField
.padding()
NewTaskUIKitTextField(
text: $viewModel.note,
focusedField: $viewModel.focusedField,
isFirstResponder: (viewModel.isInFocus && viewModel.focusedField == .note) ? true : false,
)
.frame(height: 30) // is needed to correctly size the UIKit TextField
.padding()
}
}
}
Activating focus state
In the end, let's see if we have met our requirements and how we can manage them.
- Set the initial focus state - Done. If we need it, we can set the default value using view model's
focusedField
andisInFocus
properties:
@Published var focusedField: Field? = .title
@Published var isInFocus: Bool = true
- Ability to deactivate focus state - Done. Just set the view model's
isInFocus
tofalse
. For instance, when the user taps the date button, we can call the method:
private func didTapDateButton() {
viewModel.isInFocus = false
...
}
- Ability to restore focus state - Done. As we store the last focused field in the
focusedField
, it will be focused when we setisInFocus
totrue
. In our example, when the user has finished picking the date, changeisInFocus
back totrue
.
Conclusion
Managing focus state is a common task in app development and it's worth adding some logic to be able to control it. The implementation above has some flexibility and is easy to implement.
I hope you enjoyed this article. If you have any questions, suggestions, or feedback, please let me know on Twitter.
Thanks for reading!