Reputation: 851
I started creating a simple iOS app that does some operations.
But I'm having some problems when the keyboard appears, hiding one of my textfields.
I think it's a common problem and I did some research but I couldn't find anything that solved my problem.
I want to use a ScrollView rather than animate the textfield to make it visible.
Upvotes: 80
Views: 97420
Reputation: 836
It's a pretty common thing we have to work with during the development process. Conceptually, if any view inside UIScrollView
gets covered by the system keyboard, you have to follow a few steps to make it work perfectly:
The most important part to answer clearly is providing conception that you can adapt in your real code environment with any architecture you have. So, next steps assume that we have some custom UIViewController
that has access to some custom UIView
with UIScrollView
and your accidentally covered UI (any UIView
) inside UIScrollView
.
NotificationCenter
is a dispatch mechanism that allows us to subscribe for any information we need as observers and, luckily, for keyboard events too. So, our custom UIViewController
will be an observer for keyboard events from NotificationCenter
. NotificationCenter
uses notifications (NSNotification
) as a way of broadcasting that contains some userInfo
with useful information inside. That's what we'll work with. So, let's dive into implementation finally!
We need to write a selector that will be used for handling income notification that will come each time the keyboard appears to fetch all the neccesary data from event:
import UIKit
import os
final class Controller: UIViewController {
// let's assume it's your custom view
// as instance of class `View` with
// `UIScrollView` inside and your covered UI as well:
private let body: View
// step 1:
@objc
private func willShowKeyboard(from notification: NSNotification) {
// #1
guard let screen = notification.object as? UIScreen else {
Logger().error("\(#function) invalid screen object in notification")
return
}
// #2
guard let userInfo = notification.userInfo else {
Logger().error("\(#function) no user info in notification")
return
}
// #3
guard let frameEnd = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else {
Logger().error("\(#function) no keyboard frame in user info from notification")
return
}
// #4
let duration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey]
let curve = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey]
guard
let animationDuration = duration as? Double,
let animationCurve = curve as? UInt
else {
Logger().error("\(#function) no correct animation duration or curve in user info from notification")
return
}
// #5
let viewCoordinateSpace: UICoordinateSpace = self.body
let globalCoordinatesSpace: UICoordinateSpace = screen.coordinateSpace
let keyboardFrame = globalCoordinateSpace.convert(frame.cgRectValue, to: viewCoordinateSpace)
// #6
let textFieldFrame = self.body.textField.frame
let keyboardMinY = keyboardFrame.origin.y
// let's assume it's a normal space between
// keyboard and the bottom edge of text field:
let keyboardTopMargin: CGFloat = 20
let haveToBeVisibleY = textFieldFrame.origin.y + textFieldFrame.height + keyboardTopMargin
let yDifference = haveToBeVisibleY - keyboardMinY
// is bottom edge of the text field visually lower than a top edge of the keyboard?
if yDifference > .zero {
// #7
let options: UIView.AnimationOptions = .init(rawValue: animationCurve << 16)
// #8
let offsetBefore = self.body.scrollView.contentOffset
let newOffset = CGPoint(x: offsetBefore.x, y: offsetBefore.y + offset)
// #9
UIView.animate(withDuration: animationDuration, delay: .zero, options: options) {
self.scrollView.contentOffset = newOffset
}
}
}
}
Let me try explain the code upper with a few more details below:
UIScreen
instance that later we use in step #userInfo
, we're trying to retrieve a hasmap of useful informationUIResponder.keyboardFrameBeginUserInfoKey
.yDifference
).UIView.AnimationOptions
's value we need to shift left our raw value by 16 bits to avoid zeroes on the left of the range of bits in raw value.Don't worry, if you read it, all the next steps will be much easier.
As we've written a function that handles keyboard appearance, we also need to write a function that scrolls the content back each time the keyboard will dissapear:
import UIKit
import os
final class Controller: UIViewController {
// some code from step 1
@objc
private func willHideKeyboard(from notification: NSNotification) {
// or any other value such a previous value before the keyboard has appeared.
self.body.scrollView.contentOffset = .zero
}
}
Alright, since we've written the core logic when the keyboard appears and dissapears, all we gotta do is just add these selectors to the notification center:
import UIKit
import os
final class Controller: UIVIewController {
/*
some code from step 1, 2
*/
private func subscribeForKeyboardEvents() {
self.subscribeForKeyboardAppearance()
self.subscribeForKeyboardDissapearance()
}
private func subscribeForKeyboardAppearance() {
let selector = #selector(self.willShowKeyboard(from:))
let name = UIResponder.keyboardWillShowNotification
NotificationCenter
.default
.addObserver(self, selector: selector, name: name, object: nil)
}
private func subscribeForKeyboardDissapearance() {
let selector = #selector(self.willHideKeyboard(from:))
let name = UIResponder.keyboardWillHideNotification
NotificationCenter
.default
.addObserver(self, selector: selector, name: name, object: nil)
}
}
And if the view controller and its view will be destroyed from memory, it would be great to consider prepared methods to unsubscribe from keyboard events in a similar way as we did in the previous step but vice-versa:
// some code from steps 1, 2 and 3
private func unsubscribeFromKeyboardEvents() {
self.unsubscribeFromKeyboardAppearance()
self.unsubscribeFromKeyboardDisappearance()
}
private func unsubscribeFromKeyboardAppearance() {
let name = UIResponder.keyboardWillShowNotification
NotificationCenter
.default
.removeObserver(self, name: name, object: nil)
}
private func unsubscribeFromKeyboardDisappearance() {
let name = UIResponder.keyboardWillHideNotification
NotificationCenter
.default
.removeObserver(self, name: name, object: nil)
}
We just call functions from steps 3 and 4 in appropriate view-life-cycle methods as below:
final class Controller: UIViewController {
// code from previous steps
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.subscribeForKeyboardEvents()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
self.unsubscribeFromKeyboardEvents()
}
}
Read more in the official documentation by Apple:
Upvotes: 1
Reputation: 149
This is a refinement on Zachary Probst solution posted above. I ran into a few issues with his solution and fixed it and enhanced it a bit.
This version does not need to pass in a list of UITextView controls. It finds the first responder in the current view. It also handles UITextView controls at any level in the View hierarchy.
I think his safeArea calculation wasn't quite right scrollView.contentOffset.y needed sign changed. It didn't show up if it was not scrolled. This fixed incremental scrolling. It might have been from other changes I made.
This works if the user jumps around to other UITextViews while the keyboard is up.
This is a Base Class I use for a bunch of ViewControllers. The inherited ViewController just needs to set the UIScrollViewer which activates this code behavior.
class ThemeAwareViewController: UIViewController
{
var scrollViewForKeyBoard: UIScrollView? = nil
var saveOffsetForKeyBoard: CGPoint?
func findViewThatIsFirstResponder(view: UIView) -> UIView?
{
if view.isFirstResponder {
return view
}
for subView in view.subviews {
if let hit = findViewThatIsFirstResponder(view: subView) {
return hit
}
}
return nil
}
@objc func keyboardWillShow(notification: NSNotification) {
if let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue {
if let scrollView = scrollViewForKeyBoard {
var safeArea = self.view.frame
safeArea.size.height -= scrollView.contentOffset.y
safeArea.size.height -= keyboardSize.height
safeArea.size.height -= view.safeAreaInsets.bottom
let activeField: UIView? = findViewThatIsFirstResponder(view: view)
if let activeField = activeField {
// This line had me stumped for a while (I was passing in .frame)
let activeFrameInView = view.convert(activeField.bounds, from: activeField)
let distance = activeFrameInView.maxY - safeArea.size.height
if saveOffsetForKeyBoard == nil {
saveOffsetForKeyBoard = scrollView.contentOffset
}
scrollView.setContentOffset(CGPoint(x: 0, y: distance), animated: true)
}
}
}
}
@objc func keyboardWillHide(notification: NSNotification) {
guard let restoreOffset = saveOffsetForKeyBoard else {
return
}
if let scrollView = scrollViewForKeyBoard {
scrollView.setContentOffset(restoreOffset, animated: true)
self.saveOffsetForKeyBoard = nil
}
}
}
Upvotes: 3
Reputation: 2954
In ViewDidLoad, register the notifications:
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name:UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name:UIResponder.keyboardWillHideNotification, object: nil)
Add below observer methods which does the automatic scrolling when keyboard appears.
@objc func keyboardWillShow(notification:NSNotification) {
guard let userInfo = notification.userInfo else { return }
var keyboardFrame:CGRect = (userInfo[UIResponder.keyboardFrameBeginUserInfoKey] as! NSValue).cgRectValue
keyboardFrame = self.view.convert(keyboardFrame, from: nil)
var contentInset:UIEdgeInsets = self.scrollView.contentInset
contentInset.bottom = keyboardFrame.size.height + 20
scrollView.contentInset = contentInset
}
@objc func keyboardWillHide(notification:NSNotification) {
let contentInset:UIEdgeInsets = UIEdgeInsets.zero
scrollView.contentInset = contentInset
}
Upvotes: 168
Reputation: 8610
Here is a complete solution, utilizing guard and concise code. Plus correct code in keyboardWillHide
to only reset the bottom
to 0.
@IBOutlet private weak var scrollView: UIScrollView!
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
registerNotifications()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
scrollView.contentInset.bottom = 0
}
private func registerNotifications() {
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
}
@objc private func keyboardWillShow(notification: NSNotification){
guard let keyboardFrame = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else { return }
scrollView.contentInset.bottom = view.convert(keyboardFrame.cgRectValue, from: nil).size.height
}
@objc private func keyboardWillHide(notification: NSNotification){
scrollView.contentInset.bottom = 0
}
Upvotes: 41
Reputation: 1786
In my situation it was
ScrollView --> TableView --> TableViewCell
So I had to get y position in relative to keyboard frame and check if keyboard y position and my active field y position was intersecting or not
@objc func keyboardWillShow(_ notification: Foundation.Notification) {
var userInfo = notification.userInfo!
var keyboardFrame:CGRect = (userInfo[UIResponder.keyboardFrameEndUserInfoKey] as! NSValue).cgRectValue
keyboardFrame = self.view.convert(keyboardFrame, from: nil)
var contentInset:UIEdgeInsets = self.scrollView!.contentInset
contentInset.bottom = keyboardFrame.size.height
let loc = self.activeTextField?.convert(activeTextField!.bounds, to: self.view)
if keyboardFrame.origin.y < loc!.origin.y {
self.scrollView?.contentOffset = CGPoint.init(x: (self.scrollView?.contentOffset.x)!, y: loc!.origin.y)
}
if self.scrollView?.contentInset.bottom == 0 {
self.scrollView?.contentInset = contentInset
}
}
Upvotes: 0
Reputation: 199
Swift 5 Only adjust ScrollView when TextField is hidden by keyboard (for multiple TextFields)
Add / Remove Observers:
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
NotificationCenter.default.removeObserver(self)
}
Keep track of these values so you can return to your original position:
var scrollOffset : CGFloat = 0
var distance : CGFloat = 0
Adjust ScrollView contentOffset depending on TextField Location:
@objc func keyboardWillShow(notification: NSNotification) {
if let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue {
var safeArea = self.view.frame
safeArea.size.height += scrollView.contentOffset.y
safeArea.size.height -= keyboardSize.height + (UIScreen.main.bounds.height*0.04) // Adjust buffer to your liking
// determine which UIView was selected and if it is covered by keyboard
let activeField: UIView? = [textFieldA, textViewB, textFieldC].first { $0.isFirstResponder }
if let activeField = activeField {
if safeArea.contains(CGPoint(x: 0, y: activeField.frame.maxY)) {
print("No need to Scroll")
return
} else {
distance = activeField.frame.maxY - safeArea.size.height
scrollOffset = scrollView.contentOffset.y
self.scrollView.setContentOffset(CGPoint(x: 0, y: scrollOffset + distance), animated: true)
}
}
// prevent scrolling while typing
scrollView.isScrollEnabled = false
}
}
@objc func keyboardWillHide(notification: NSNotification) {
if distance == 0 {
return
}
// return to origin scrollOffset
self.scrollView.setContentOffset(CGPoint(x: 0, y: scrollOffset), animated: true)
scrollOffset = 0
distance = 0
scrollView.isScrollEnabled = true
}
Make sure to use [UIResponder.keyboardFrameEndUserInfoKey] to get the proper keyboard height the first time.
Upvotes: 10
Reputation: 415
for Swift 4.0
In ViewDidLoad
// setup keyboard event
NotificationCenter.default.addObserver(self, selector: #selector(self.keyboardWillShow), name: NSNotification.Name.UIKeyboardWillShow, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(self.keyboardWillHide), name: NSNotification.Name.UIKeyboardWillHide, object: nil)
Add below observer methods which does the automatic scrolling when keyboard appears.
@objc func keyboardWillShow(notification:NSNotification){
var userInfo = notification.userInfo!
var keyboardFrame:CGRect = (userInfo[UIKeyboardFrameBeginUserInfoKey] as! NSValue).cgRectValue
keyboardFrame = self.view.convert(keyboardFrame, from: nil)
var contentInset:UIEdgeInsets = self.ui_scrollView.contentInset
contentInset.bottom = keyboardFrame.size.height
ui_scrollView.contentInset = contentInset
}
@objc func keyboardWillHide(notification:NSNotification){
let contentInset:UIEdgeInsets = UIEdgeInsets.zero
ui_scrollView.contentInset = contentInset
}
Upvotes: 17
Reputation: 177
In case anyone is looking for Objective-C code for this solution:
- (void)keyboardWasShown:(NSNotification *)notification {
NSDictionary* info = [notification userInfo];
CGSize kbSize = [[info objectForKey:UIKeyboardFrameBeginUserInfoKey] CGRectValue].size;
UIEdgeInsets contentInsets = baseScrollView.contentInset;
contentInsets.bottom = kbSize.height;
baseScrollView.contentInset = contentInsets;
}
- (void)keyboardWillBeHidden:(NSNotification *)notification {
UIEdgeInsets contentInsets = UIEdgeInsetsZero;
baseScrollView.contentInset = contentInsets;
[baseScrollView endEditing:YES];
}
Upvotes: 0
Reputation: 466
In Swift4, just add the following extension.
extension UIViewController {
func setupViewResizerOnKeyboardShown() {
NotificationCenter.default.addObserver(self,
selector: #selector(self.keyboardWillShowForResizing),
name:
Notification.Name.UIKeyboardWillShow,
object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(self.keyboardWillHideForResizing),
name: Notification.Name.UIKeyboardWillHide,
object: nil)
}
@objc func keyboardWillShowForResizing(notification: Notification) {
if let keyboardSize = (notification.userInfo?[UIKeyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue,
let window = self.view.window?.frame {
// We're not just minusing the kb height from the view height because
// the view could already have been resized for the keyboard before
self.view.frame = CGRect(x: self.view.frame.origin.x,
y: self.view.frame.origin.y,
width: self.view.frame.width,
height: window.origin.y + window.height - keyboardSize.height)
} else {
debugPrint("We're showing the keyboard and either the keyboard size or window is nil: panic widely.")
}
}
@objc func keyboardWillHideForResizing(notification: Notification) {
if let keyboardSize = (notification.userInfo?[UIKeyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue {
let viewHeight = self.view.frame.height
self.view.frame = CGRect(x: self.view.frame.origin.x,
y: self.view.frame.origin.y,
width: self.view.frame.width,
height: viewHeight + keyboardSize.height)
} else {
debugPrint("We're about to hide the keyboard and the keyboard size is nil. Now is the rapture.")
}
}
}
Upvotes: 2
Reputation: 345
From the answer by Sudheer Palchuri, converted for Swift 4.
In ViewDidLoad, register the notifications:
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name:NSNotification.Name.UIKeyboardWillShow, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name:NSNotification.Name.UIKeyboardWillHide, object: nil)
And then:
// MARK: - Keyboard Delegates
func textFieldShouldReturn(textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
@objc func keyboardWillShow(notification:NSNotification){
var userInfo = notification.userInfo!
var keyboardFrame:CGRect = (userInfo[UIKeyboardFrameBeginUserInfoKey] as! NSValue).cgRectValue
keyboardFrame = self.view.convert(keyboardFrame, from: nil)
var contentInset:UIEdgeInsets = self.scrollView.contentInset
contentInset.bottom = keyboardFrame.size.height
self.scrollView.contentInset = contentInset
}
@objc func keyboardWillHide(notification:NSNotification){
let contentInset:UIEdgeInsets = UIEdgeInsets.zero
self.scrollView.contentInset = contentInset
}
Upvotes: 6
Reputation: 225
An answer for Swift 3, based on the one proposed by Daniel Jones, but safer (thanks to the guard), more concise and with consistent scroll indicator insets:
@objc private func keyboardWillBeShown(notification: NSNotification) {
guard let keyboardFrameValue = notification.userInfo?[UIKeyboardFrameBeginUserInfoKey] as? NSValue else { return }
let keyboardFrame = view.convert(keyboardFrameValue.cgRectValue, from: nil)
scrollView.contentInset.bottom = keyboardFrame.size.height
scrollView.scrollIndicatorInsets = scrollView.contentInset
}
@objc private func keyboardWillBeHidden() {
scrollView.contentInset = .zero
scrollView.scrollIndicatorInsets = scrollView.contentInset
}
Upvotes: 2
Reputation: 2555
contentInset
doesn't work for me, because I want the scrollview move all the way up above the keyboard. So I use contentOffset
:
func keyboardWillShow(notification:NSNotification) {
guard let keyboardFrameValue = notification.userInfo?[UIKeyboardFrameBeginUserInfoKey] as? NSValue else {
return
}
let keyboardFrame = view.convert(keyboardFrameValue.cgRectValue, from: nil)
scrollView.contentOffset = CGPoint(x:0, y:keyboardFrame.size.height)
}
func keyboardWillHide(notification:NSNotification) {
scrollView.contentOffset = .zero
}
Upvotes: 11
Reputation: 851
Reading the links you sent to me, I found a way to make it work, thanks!:
func textFieldDidBeginEditing(textField: UITextField) {
if (textField == //your_field) {
scrollView.setContentOffset(CGPointMake(0, field_extra.center.y-280), animated: true)
callAnimation()
viewDidLayoutSubviews()
}
}
func textFieldDidEndEditing(textField: UITextField) {
if (textField == //your_field){
scrollView .setContentOffset(CGPointMake(0, 0), animated: true)
viewDidLayoutSubviews()
}
}
Upvotes: 5
Reputation: 1082
The top answer for swift 3:
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name:NSNotification.Name.UIKeyboardWillShow, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name:NSNotification.Name.UIKeyboardWillHide, object: nil)
And then:
func keyboardWillShow(notification:NSNotification){
//give room at the bottom of the scroll view, so it doesn't cover up anything the user needs to tap
var userInfo = notification.userInfo!
var keyboardFrame:CGRect = (userInfo[UIKeyboardFrameBeginUserInfoKey] as! NSValue).cgRectValue
keyboardFrame = self.view.convert(keyboardFrame, from: nil)
var contentInset:UIEdgeInsets = self.theScrollView.contentInset
contentInset.bottom = keyboardFrame.size.height
theScrollView.contentInset = contentInset
}
func keyboardWillHide(notification:NSNotification){
let contentInset:UIEdgeInsets = UIEdgeInsets.zero
theScrollView.contentInset = contentInset
}
Upvotes: 74
Reputation: 893
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
NotificationCenter.default.addObserver(self, selector: #selector(ViewController.keyboardWillShow), name: NSNotification.Name.UIKeyboardWillShow, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(ViewController.keyboardWillHide), name: NSNotification.Name.UIKeyboardWillHide, object: nil)
}
func keyboardWillShow(_ notification:Notification) {
if let keyboardSize = (notification.userInfo?[UIKeyboardFrameBeginUserInfoKey] as? NSValue)?.cgRectValue {
tableView.contentInset = UIEdgeInsetsMake(0, 0, keyboardSize.height, 0)
}
}
func keyboardWillHide(_ notification:Notification) {
if let keyboardSize = (notification.userInfo?[UIKeyboardFrameBeginUserInfoKey] as? NSValue)?.cgRectValue {
tableView.contentInset = UIEdgeInsetsMake(0, 0, 0, 0)
}
}
Upvotes: 0
Reputation: 1389
You can animate your scrollview to center on your UITextField on keyboard appearance (ie. making your textfield the first responder) via a scroll offset. Here are a couple of good resources to get you started (there are a bunch on this site):
How programmatically move a UIScrollView to focus in a control above keyboard?
How to make a UIScrollView auto scroll when a UITextField becomes a first responder
Additionally, if you simply use a UITableView with your content in cells, when the textfield becomes first responder, the UITableViewController will automatically scroll to the textfield cell for you (though I'm not sure this is what you want to do).
Upvotes: 2