Move TextField up when the keyboard has appeared in SwiftUI Move TextField up when the keyboard has appeared in SwiftUI ios ios

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)

When the keyboard is opened, the 3 textfields are moved up enough to keep then all visible

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)

When each text field is clicked, the view is only moved up enough to make the clicked text field visible.

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 SwiftUIimport Combinefinal 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 SwiftUIimport Combinestruct 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 SwiftUIfinal 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.