Move TextField up when the keyboard has appeared in SwiftUI
Code updated for the Xcode, beta 7.
You do not need padding, ScrollViews or Lists to achieve this. Although this solution will play nice with them too. I am including two examples here.
The first one moves all textField up, if the keyboard appears for any of them. But only if needed. If the keyboard doesn't hide the textfields, they will not move.
In the second example, the view only moves enough just to avoid hiding the active textfield.
Both examples use the same common code found at the end: GeometryGetter and KeyboardGuardian
First Example (show all textfields)
struct ContentView: View {
@ObservedObject private var kGuardian = KeyboardGuardian(textFieldCount: 1)
@State private var name = Array<String>.init(repeating: "", count: 3)
var body: some View {
VStack {
Group {
Text("Some filler text").font(.largeTitle)
Text("Some filler text").font(.largeTitle)
}
TextField("enter text #1", text: $name[0])
.textFieldStyle(RoundedBorderTextFieldStyle())
TextField("enter text #2", text: $name[1])
.textFieldStyle(RoundedBorderTextFieldStyle())
TextField("enter text #3", text: $name[2])
.textFieldStyle(RoundedBorderTextFieldStyle())
.background(GeometryGetter(rect: $kGuardian.rects[0]))
}.offset(y: kGuardian.slide).animation(.easeInOut(duration: 1.0))
}
}
Second Example (show only the active field)
struct ContentView: View {
@ObservedObject private var kGuardian = KeyboardGuardian(textFieldCount: 3)
@State private var name = Array<String>.init(repeating: "", count: 3)
var body: some View {
VStack {
Group {
Text("Some filler text").font(.largeTitle)
Text("Some filler text").font(.largeTitle)
}
TextField("text #1", text: $name[0], onEditingChanged: { if $0 { self.kGuardian.showField = 0 } })
.textFieldStyle(RoundedBorderTextFieldStyle())
.background(GeometryGetter(rect: $kGuardian.rects[0]))
TextField("text #2", text: $name[1], onEditingChanged: { if $0 { self.kGuardian.showField = 1 } })
.textFieldStyle(RoundedBorderTextFieldStyle())
.background(GeometryGetter(rect: $kGuardian.rects[1]))
TextField("text #3", text: $name[2], onEditingChanged: { if $0 { self.kGuardian.showField = 2 } })
.textFieldStyle(RoundedBorderTextFieldStyle())
.background(GeometryGetter(rect: $kGuardian.rects[2]))
}.offset(y: kGuardian.slide).animation(.easeInOut(duration: 1.0))
}.onAppear { self.kGuardian.addObserver() }
.onDisappear { self.kGuardian.removeObserver() }
}
GeometryGetter
This is a view that absorbs the size and position of its parent view. In order to achieve that, it is called inside the .background modifier. This is a very powerful modifier, not just a way to decorate the background of a view. When passing a view to .background(MyView()), MyView is getting the modified view as the parent. Using GeometryReader is what makes it possible for the view to know the geometry of the parent.
For example: Text("hello").background(GeometryGetter(rect: $bounds))
will fill variable bounds, with the size and position of the Text view, and using the global coordinate space.
struct GeometryGetter: View {
@Binding var rect: CGRect
var body: some View {
GeometryReader { geometry in
Group { () -> AnyView in
DispatchQueue.main.async {
self.rect = geometry.frame(in: .global)
}
return AnyView(Color.clear)
}
}
}
}
Update I added the DispatchQueue.main.async, to avoid the possibility of modifying the state of the view while it is being rendered.***
KeyboardGuardian
The purpose of KeyboardGuardian, is to keep track of keyboard show/hide events and calculate how much space the view needs to be shifted.
Update: I modified KeyboardGuardian to refresh the slide, when the user tabs from one field to another
import SwiftUI
import Combine
final class KeyboardGuardian: ObservableObject {
public var rects: Array<CGRect>
public var keyboardRect: CGRect = CGRect()
// keyboardWillShow notification may be posted repeatedly,
// this flag makes sure we only act once per keyboard appearance
public var keyboardIsHidden = true
@Published var slide: CGFloat = 0
var showField: Int = 0 {
didSet {
updateSlide()
}
}
init(textFieldCount: Int) {
self.rects = Array<CGRect>(repeating: CGRect(), count: textFieldCount)
}
func addObserver() {
NotificationCenter.default.addObserver(self, selector: #selector(keyBoardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyBoardDidHide(notification:)), name: UIResponder.keyboardDidHideNotification, object: nil)
}
func removeObserver() {
NotificationCenter.default.removeObserver(self)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
@objc func keyBoardWillShow(notification: Notification) {
if keyboardIsHidden {
keyboardIsHidden = false
if let rect = notification.userInfo?["UIKeyboardFrameEndUserInfoKey"] as? CGRect {
keyboardRect = rect
updateSlide()
}
}
}
@objc func keyBoardDidHide(notification: Notification) {
keyboardIsHidden = true
updateSlide()
}
func updateSlide() {
if keyboardIsHidden {
slide = 0
} else {
let tfRect = self.rects[self.showField]
let diff = keyboardRect.minY - tfRect.maxY
if diff > 0 {
slide += diff
} else {
slide += min(diff, 0)
}
}
}
}
I tried many of the proposed solutions, and even though they work in most cases, I had some issues - mainly with safe area (I have a Form inside TabView's tab).
I ended up combining few different solutions, and using GeometryReader in order to get specific view's safe area bottom inset and use it in padding's calculation:
import SwiftUI
import Combine
struct AdaptsToKeyboard: ViewModifier {
@State var currentHeight: CGFloat = 0
func body(content: Content) -> some View {
GeometryReader { geometry in
content
.padding(.bottom, self.currentHeight)
.onAppear(perform: {
NotificationCenter.Publisher(center: NotificationCenter.default, name: UIResponder.keyboardWillShowNotification)
.merge(with: NotificationCenter.Publisher(center: NotificationCenter.default, name: UIResponder.keyboardWillChangeFrameNotification))
.compactMap { notification in
withAnimation(.easeOut(duration: 0.16)) {
notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
}
}
.map { rect in
rect.height - geometry.safeAreaInsets.bottom
}
.subscribe(Subscribers.Assign(object: self, keyPath: \.currentHeight))
NotificationCenter.Publisher(center: NotificationCenter.default, name: UIResponder.keyboardWillHideNotification)
.compactMap { notification in
CGFloat.zero
}
.subscribe(Subscribers.Assign(object: self, keyPath: \.currentHeight))
})
}
}
}
extension View {
func adaptsToKeyboard() -> some View {
return modifier(AdaptsToKeyboard())
}
}
Usage:
struct MyView: View {
var body: some View {
Form {...}
.adaptsToKeyboard()
}
}
To build off of @rraphael 's solution, I converted it to be usable by today's xcode11 swiftUI support.
import SwiftUI
final class KeyboardResponder: ObservableObject {
private var notificationCenter: NotificationCenter
@Published private(set) var currentHeight: CGFloat = 0
init(center: NotificationCenter = .default) {
notificationCenter = center
notificationCenter.addObserver(self, selector: #selector(keyBoardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil)
notificationCenter.addObserver(self, selector: #selector(keyBoardWillHide(notification:)), name: UIResponder.keyboardWillHideNotification, object: nil)
}
deinit {
notificationCenter.removeObserver(self)
}
@objc func keyBoardWillShow(notification: Notification) {
if let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue {
currentHeight = keyboardSize.height
}
}
@objc func keyBoardWillHide(notification: Notification) {
currentHeight = 0
}
}
Usage:
struct ContentView: View {
@ObservedObject private var keyboard = KeyboardResponder()
@State private var textFieldInput: String = ""
var body: some View {
VStack {
HStack {
TextField("uMessage", text: $textFieldInput)
}
}.padding()
.padding(.bottom, keyboard.currentHeight)
.edgesIgnoringSafeArea(.bottom)
.animation(.easeOut(duration: 0.16))
}
}
The published currentHeight
will trigger a UI re-render and move your TextField up when the keyboard shows, and back down when dismissed. However I didn't use a ScrollView.
From iOS 14.2, TextField
s are keyboard aware by default if they have enough space to move. For example, if it is in a VStack
with a Spacer
(Look at the old demo code below without the modifier)
⚠️ It seems the following code is not working as expected for +iOS 14.2
Xcode 12 (to iOS 14.2) - One line code
Add this modifier to the TextField
.ignoresSafeArea(.keyboard, edges: .bottom)
Apple added the keyboard as a region for the safe area, so you can use it to move any View
with the keyboard like other regions.
I created a View that can wrap any other view to shrink it when the keyboard appears.
It's pretty simple. We create publishers for keyboard show/hide events and then subscribe to them using onReceive
. We use the result of that to create a keyboard-sized rectangle behind the keyboard.
struct KeyboardHost<Content: View>: View {
let view: Content
@State private var keyboardHeight: CGFloat = 0
private let showPublisher = NotificationCenter.Publisher.init(
center: .default,
name: UIResponder.keyboardWillShowNotification
).map { (notification) -> CGFloat in
if let rect = notification.userInfo?["UIKeyboardFrameEndUserInfoKey"] as? CGRect {
return rect.size.height
} else {
return 0
}
}
private let hidePublisher = NotificationCenter.Publisher.init(
center: .default,
name: UIResponder.keyboardWillHideNotification
).map {_ -> CGFloat in 0}
// Like HStack or VStack, the only parameter is the view that this view should layout.
// (It takes one view rather than the multiple views that Stacks can take)
init(@ViewBuilder content: () -> Content) {
view = content()
}
var body: some View {
VStack {
view
Rectangle()
.frame(height: keyboardHeight)
.animation(.default)
.foregroundColor(.clear)
}.onReceive(showPublisher.merge(with: hidePublisher)) { (height) in
self.keyboardHeight = height
}
}
}
You can then use the view like so:
var body: some View {
KeyboardHost {
viewIncludingKeyboard()
}
}
To move the content of the view up rather than shrinking it, padding or offset can be added to view
rather than putting it in a VStack with a rectangle.