Keyboard Avoidance for SwiftUI Views
The iOS system keyboard appears whenever a user taps an element that accepts text input. It can be a TextField
component from SwiftUI, a UITextField
and UITextView
from UIKit, or a text input field inside a web view.
Whenever the iOS keyboard appears, it overlaps parts of your interface. The common approach to keyboard management is to move up the focused part of the view to avoid its overlapping. In this article, let’s learn how we can solve this problem by making our SwiftUI views keyboard-aware.
Propagating Keyboard Height Changes with Combine
The code samples were created with Xcode 11.4, iOS 13.4, Swift 5.2, the Combine and SwiftUI frameworks.
Whenever the keyboard appears, the iOS system sends the following notifications:
- keyboardWillShowNotification
- keyboardDidShowNotification
- keyboardWillHideNotification
- keyboardDidHideNotification
We can use the willShow and willHide notifications to track the keyboard height before and after it appears on the screen. We’ll propagate keyboard height changes using the Combine framework since it integrates seamlessly with SwiftUI:
You can familiarize yourself with the Combine framework by reading this bird’s-eye overview.
extension Publishers {
// 1.
static var keyboardHeight: AnyPublisher<CGFloat, Never> {
// 2.
let willShow = NotificationCenter.default.publisher(for: UIApplication.keyboardWillShowNotification)
.map { $0.keyboardHeight }
let willHide = NotificationCenter.default.publisher(for: UIApplication.keyboardWillHideNotification)
.map { _ in CGFloat(0) }
// 3.
return MergeMany(willShow, willHide)
.eraseToAnyPublisher()
}
}
Here is what we are doing:
- Declare a keyboard height publisher in the
Publishers
namespace. The publisher has two types –CGFloat
andNever
– which means that it emits values of typeCGFloat
and can never fail with an error. - Wrap the willShow and willHide notifications into publishers. Whenever the notification center broadcasts a willShow or willHide notification, the corresponding publisher will also emit the notification as its value. We also use the
map
operator since we are only interested in keyboard height. - Combine multiple publishers into one by merging their emitted values.
This uses notification’s userInfo
to get keyboard height:
extension Notification {
var keyboardHeight: CGFloat {
return (userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect)?.height ?? 0
}
}
Moving SwiftUI View Up When Keyboard Appears
Let’s implement a SwiftUI view and make it keyboard-aware:
struct ContentView: View {
@State private var text = ""
var body: some View {
VStack {
Spacer()
TextField("Enter something", text: $text)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
.padding()
}
}
This shows a problem with the keyboard overlaying on the text field:
Next, we need to move the view up when the keyboard appears:
struct ContentView: View {
@State private var text = ""
// 1.
@State private var keyboardHeight: CGFloat = 0
var body: some View {
VStack {
Spacer()
TextField("Enter something", text: $text)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
.padding()
// 2.
.padding(.bottom, keyboardHeight)
// 3.
.onReceive(Publishers.keyboardHeight) { self.keyboardHeight = $0 }
}
}
Here is what the code does:
- Create keyboard height state. SwiftUI will automatically update the view whenever the keyboard height changes.
- Add padding to the bottom of the view, which will make it move up and down with the keyboard.
- Update the view state with the latest value emitted by the
keyboardHeight
publisher.
Now, when focusing and defocusing the text field, the view will move up and down:
Extracting Keyboard Avoidance Behavior into SwiftUI ViewModifier
Every time we add a text field to our app, chances high that will we need to manage keyboard appearances. Not to repeat ourselves every time, let’s extract the keyboard avoidance behavior into SwiftUI ViewModifier
.
ViewModifier
is a pre-made set of view configurations and state.
struct KeyboardAdaptive: ViewModifier {
@State private var keyboardHeight: CGFloat = 0
func body(content: Content) -> some View {
content
.padding(.bottom, keyboardHeight)
.onReceive(Publishers.keyboardHeight) { self.keyboardHeight = $0 }
}
}
extension View {
func keyboardAdaptive() -> some View {
ModifiedContent(content: self, modifier: KeyboardAdaptive())
}
}
Now we apply the modifier to get the same result as in the previous section:
struct ContentView: View {
@State private var text = ""
var body: some View {
VStack {
Spacer()
TextField("Enter something", text: $text)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
.padding()
.keyboardAdaptive() // Apply the modifier
}
}
Avoiding Over-scroll
There is one problem left with the KeyboardAdaptive
modifier. It always moves the view up, regardless of whether the keyboard overlaps the focused text field or not. The example below shows unwanted behavior:
Let’s extend our modifier to avoid the unnecessary padding. First, detect a focused text input field using the good old UIKit:
// From https://stackoverflow.com/a/14135456/6870041
extension UIResponder {
static var currentFirstResponder: UIResponder? {
_currentFirstResponder = nil
UIApplication.shared.sendAction(#selector(UIResponder.findFirstResponder(_:)), to: nil, from: nil, for: nil)
return _currentFirstResponder
}
private static weak var _currentFirstResponder: UIResponder?
@objc private func findFirstResponder(_ sender: Any) {
UIResponder._currentFirstResponder = self
}
var globalFrame: CGRect? {
guard let view = self as? UIView else { return nil }
return view.superview?.convert(view.frame, to: nil)
}
}
Since there can be at most one first responder in an iOS application, calling the UApplication
s sendAction()
method directs the invocation to the correct responder (if any). The globalFrame
property calculates the responder frame in the global coordinate space.
Then configure the KeyboardAdaptive
modifier to move the view only as much as necessary so that the keyboard does not overlap the view:
struct KeyboardAdaptive: ViewModifier {
@State private var bottomPadding: CGFloat = 0
func body(content: Content) -> some View {
// 1.
GeometryReader { geometry in
content
.padding(.bottom, self.bottomPadding)
// 2.
.onReceive(Publishers.keyboardHeight) { keyboardHeight in
// 3.
let keyboardTop = geometry.frame(in: .global).height - keyboardHeight
// 4.
let focusedTextInputBottom = UIResponder.currentFirstResponder?.globalFrame?.maxY ?? 0
// 5.
self.bottomPadding = max(0, focusedTextInputBottom - keyboardTop - geometry.safeAreaInsets.bottom)
}
// 6.
.animation(.easeOut(duration: 0.16))
}
}
}
Here is what we are doing:
- Access information about the size of the content view using
GeometryReader
. - In the
onReceive()
callback, we calculate how far should we move the content view to position the focused text field above the keyboard. - Calculate the top-most position of the keyboard in the global coordinate space.
- Calculate the bottom-most position of the focused text field.
- Calculate the intersection size between the keyboard and text field. This is the value of the content view’s bottom padding. Note that we take into account the bottom safe area inset.
- Match the motion animation with the speed of the keyboard sliding up and down.
Note that the
KeyboardAdaptive
modifier wraps your view in aGeometryReader
, which attempts to fill all the available space, potentially increasing content view size. Thanks to Coffeemate for pointing this out.
This fixes the over-scroll issue:
And adds the motion animation to existing behavior:
Source Code
You can find the final project here. It is published under the “Unlicense”, which allows you to do whatever you want with it.
Thanks for reading!
If you enjoyed this post, be sure to follow me on Twitter to keep up with the new content. There I write daily on iOS development, programming, and Swift.