Maintain visibility of focused UITextView in a UITableView when showing Keyboard - ios

I am building a form dynamically that has various types of UI elements for each UITableViewCell.
There is one UITableViewCell that contains a UITextView and I want to be able to maintain visibility when showing the keyboard. I have looked at the other similar questions, but have been unable to find a solution.
I wrote the Swift version of what is recommended by: Apple's Managing Keyboard
It does not work for two reasons.
1.) The Keyboard notification is fired before the TextViewWillBeginEditing.
2.) The frame of the UITextView is in relation to the superview which is the UITableViewCell, so the check is wrong.
Here is my current code:
func adjustTableViewForKeyboard(notification: NSNotification) {
let userInfo = notification.userInfo!
let keyboardScreenEndFrame = (userInfo[UIKeyboardFrameEndUserInfoKey] as! NSValue).CGRectValue()
let keyboardViewEndFrame = view.convertRect(keyboardScreenEndFrame, fromView: view.window)
if notification.name == UIKeyboardWillHideNotification {
self.tableView.contentInset = UIEdgeInsetsZero
} else {
self.tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: keyboardViewEndFrame.height, right: 0)
rect = self.view.frame;
rect!.size.height -= keyboardViewEndFrame.height
}
self.tableView.scrollIndicatorInsets = self.tableView.contentInset
}
func textViewDidBeginEditing(textView: UITextView) {
self.activeTextView = textView;
if(activeTextView != nil){
// This check does NOT work due to the TextView's superview is the cell.
if(!CGRectContainsPoint(rect!, (activeTextView?.frame.origin)!)){
self.tableView.scrollRectToVisible((activeTextView?.frame)!, animated: true)
}
}
}
This works in terms of being able to scroll to all cells, but I also want to make sure the UITextView is not hidden by keyboard.

You may be able to use the responder chain to solve this. In your notification handler, try calling isFirstResponder() on your text view; if it returns true, then you can call the table view-scrolling code from there.

I had a similar issue with a (albeit static cell) form I was building, I was able to use the scrollToRowAtIndexPath method to update the tableview's row positioning to keep my text view on screen.
func textViewDidBeginEditing(textView: UITextView) {
if textView == notesTextView {
tableView.scrollToRowAtIndexPath(NSIndexPath(forRow: 0, inSection: 3), atScrollPosition: .Top, animated: true)
}
}
It does require knowledge of the index path section/row and the text view reference in case of multiples, but might be of use?
scrollRectToVisible should also work, but sounds like you have to convert the textview's frame to coordinate system of the scrollview using convertRect:toView or similar first.
Hope this helps - Cheers.

Related

safeAreaInsets in UIView is 0 on an iPhone X

I am updating my app to adapt it for iPhone X. All views work fine by now except one. I have a view controller that presents a custom UIView that covers the whole screen. Before I was using UIScreen.main.bounds to find out the size of the view before all layout was done (I need it for putting the correct itemSize for a collectionView). I thought that now I could do something like
UIScreen.main.bounds.size.height - safeAreaInsets.bottom to get the right usable size. The problem is, safeAreaInsets returns (0,0,0,0) trying on an iPhone X (Simulator). Any ideas? In other views, I get the right numbers for safeAreaInsets.
Thank you!
I recently had a similar problem where the safe area insets are returning (0, 0, 0, 0) as soon as viewDidLoad is triggered. It seems that they are set fractionally later than the rest of the view loading.
I got round it by overriding viewSafeAreaInsetsDidChange and doing my layout in that instead:
override func viewSafeAreaInsetsDidChange() {
// ... your layout code here
}
I already figure out the solution: I was doing all the implementation in the init of the view. safeAreaInsets has the correct size in layoutSubviews()
I've run into this issue too trying to move up views to make way for the keyboard on the iPhone X. The safeAreaInsets of the main view are always 0, even though I know the subviews have been laid out at this point as the screen has been drawn. A work around I found, as and mentioned above, is to get the keyWindow and check its safe area insets instead.
Obj-C:
CGFloat bottomInset = [UIApplication sharedApplication].keyWindow.safeAreaInsets.bottom;
Swift:
let bottomInset = UIApplication.shared.keyWindow?.safeAreaInsets.bottom
You can then use this value to adjust constraints or view frames as required.
I have a view which is a subview inside another view.
I found that I can't get safeAreaInsets correctly, it always return 0, in that view on iPhoneX even if I put it in layoutSubviews.
The final solution is I use following UIScreen extension to detect safeAreaInsets which can work like a charm.
extension UIScreen {
func widthOfSafeArea() -> CGFloat {
guard let rootView = UIApplication.shared.keyWindow else { return 0 }
if #available(iOS 11.0, *) {
let leftInset = rootView.safeAreaInsets.left
let rightInset = rootView.safeAreaInsets.right
return rootView.bounds.width - leftInset - rightInset
} else {
return rootView.bounds.width
}
}
func heightOfSafeArea() -> CGFloat {
guard let rootView = UIApplication.shared.keyWindow else { return 0 }
if #available(iOS 11.0, *) {
let topInset = rootView.safeAreaInsets.top
let bottomInset = rootView.safeAreaInsets.bottom
return rootView.bounds.height - topInset - bottomInset
} else {
return rootView.bounds.height
}
}
}
I try to use "self.view.safeAreaInset" in a view controller. First, it is a NSInsetZero when I use it in the controller's life cycle method "viewDidLoad", then I search it from the net and get the right answer, the log is like:
ViewController loadView() SafeAreaInsets :UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0)
ViewController viewDidLoad() SafeAreaInsets :UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0)
ViewController viewWillAppear() SafeAreaInsets :UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0)
ViewController viewDidLayoutSubviews() SafeAreaInsets :UIEdgeInsets(top: 44.0, left: 0.0, bottom: 34.0, right: 0.0)
ViewController viewDidAppear() SafeAreaInsets :UIEdgeInsets(top: 44.0, left: 0.0, bottom: 34.0, right: 0.0)
so you can choice the right method that you need the safeAreaInset and use it!
Swift iOS 11,12,13+
var insets : UIEdgeInsets = .zero
override func viewDidLoad() {
super.viewDidLoad()
insets = UIApplication.shared.delegate?.window??.safeAreaInsets ?? .zero
//Or you can use this
insets = self.view.safeAreaInsets
}
In my case I was adding a UICollectionView inside viewDidLoad()
collectionView = UICollectionView(frame: view.safeAreaLayoutGuide.layoutFrame, collectionViewLayout: createCompositionalLayout())
Unfortunately at this stage safeAreaLayoutGuide is still zero.
I solved it by adding:
override func viewDidLayoutSubviews() {
collectionView.frame = view.safeAreaLayoutGuide.layoutFrame
}
the viewDidAppear(_:) method of the container view controller that extends the safe area of its embedded child view controller to account for the views in .
Make your modifications in this method because the safe area insets for a view are not accurate until the view is added to a view hierarchy.
override func viewDidAppear(_ animated: Bool) {
if (#available(iOS 11, *)) {
var newSafeArea = view.safeAreaInsets
// Adjust the safe area to accommodate
// the width of the side view.
if let sideViewWidth = sideView?.bounds.size.width {
newSafeArea.right += sideViewWidth
}
// Adjust the safe area to accommodate
// the height of the bottom view.
if let bottomViewHeight = bottomView?.bounds.size.height {
newSafeArea.bottom += bottomViewHeight
}
// Adjust the safe area insets of the
// embedded child view controller.
let child = self.childViewControllers[0]
child.additionalSafeAreaInsets = newSafeArea
}
}
I've come across the same problem. In my case the view I'm inserting would be sized correctly after calling view.layoutIfNeeded(). The view.safeAreaInsets was set after this, but only the top value was correct. The bottom value was still 0 (this on an iPhone X).
While trying to figure out at what point the safeAreaInsets are set correctly, I've added a breakpoint on the view's safeAreaInsetsDidChange() method. This was being called multiple times, but only when I saw CALayer.layoutSublayers() in the backtrace the value had been set correctly.
So I've replaced view.layoutIfNeeded() by the CALayer's counterpart view.layer.layoutIfNeeded(), which resulted in the safeAreaInsets to be set correctly right away, thus solving my problem.
TL;DR
Replace
view.layoutIfNeeded()
by
view.layer.layoutIfNeeded()
[UIApplication sharedApplication].keyWindow.safeAreaInsets return none zero
Just try self.view.safeAreaInsets instead of UIApplication.shared.keyWindow?.safeAreaInsets
Safe area insets seems to not fill on iOS 11.x.x devices when requested via application keyWindow.
View layout is never guaranteed until layoutSubviews or viewDidLayoutSubviews. Never rely on sizes before these lifecycle methods. You will get inconsistent results if you do.
To calculate safe area safeAreaInsets, try to obtain it in viewWIllAppear(), as in didLoad() the view have not been formed.
You will have the correct inset in willAppear!
In case you cannot subclass, you can use this UIView extension.
It gives you an API like this:
view.onSafeAreaInsetsDidChange = { [unowned self] in
self.updateSomeLayout()
}
The extension adds an onSafeAreaInsetsDidChange property using object association. Then swizzles the UIView.safeAreaInsetsDidChange() method to call the closure (if any).
extension UIView {
typealias Action = () -> Void
var onSafeAreaInsetsDidChange: Action? {
get {
associatedObject(for: "onSafeAreaInsetsDidChange") as? Action
}
set {
Self.swizzleSafeAreaInsetsDidChangeIfNeeded()
set(associatedObject: newValue, for: "onSafeAreaInsetsDidChange")
}
}
static var swizzled = false
static func swizzleSafeAreaInsetsDidChangeIfNeeded() {
guard swizzled == false else { return }
swizzle(
method: "safeAreaInsetsDidChange",
originalSelector: #selector(originalSafeAreaInsetsDidChange),
swizzledSelector: #selector(swizzledSafeAreaInsetsDidChange),
for: Self.self
)
swizzled = true
}
#objc func originalSafeAreaInsetsDidChange() {
// Original implementaion will be copied here.
}
#objc func swizzledSafeAreaInsetsDidChange() {
originalSafeAreaInsetsDidChange()
onSafeAreaInsetsDidChange?()
}
}
It uses some helpers (see NSObject+Extensions.swift and NSObject+Swizzle.swift), but you don't really need it if you use sizzling and object association APIs directly.

UITableViewCells animating at different rate than collapsing tableHeaderView

I am trying ot troubleshoot a tableView header (pink) that is animating a collapse. As the tableViewHeader height is shrinking the table view cells should pull up with the top of their tableView (orange). The beginning and end states are correct, but somehow the table view cells are animating up at a different rate. Something is clearly wrong here, I just can't seem to pinpoint what it is.
It appears to have something to do with the fact that I am using self sizing table view cells and tableView.rowHeight = UITableViewAutomaticDimension. If I use fixed height cells everything is fine.
Beginning State:
Middle State (Note cells already sliding under header):
Final State (Final state of layout is correct):
Here is the code that animates the collapse.
func collapseHeader() {
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseIn, animations: {
self.tableView.beginUpdates()
if let header = self.tableView.tableHeaderView as? TopicTableHeaderView {
header.setHeaderState(state: .collapsed)
}
self.sizeHeaderToFit()
self.view.layoutIfNeeded()
self.tableView.endUpdates()
}) { (bool) in
print("collapse completed")
}
}
func sizeHeaderToFit() {
if let headerView = tableView.tableHeaderView {
let height = headerView.systemLayoutSizeFitting(UILayoutFittingCompressedSize).height
var frame = headerView.frame
frame.size.height = height
if headerView.frame.height != height {
headerView.frame = frame
tableView.tableHeaderView = headerView
headerView.setNeedsLayout()
headerView.layoutIfNeeded()
}
}
}
And the problem was simply where I was calling self.tableView.beginUpdates(). I moved that to the line directly above self.tableView.endUpdates() and that solved the problem.

UITextView with other elements (similar to Evernote) iOS

I'm trying to implement a view very similar to Evernote's screen in which you add a New Note.
It seems like a UITableView embedded in a NavigationController. This tableview contains static cells (2 or 3) with the bottom one being a UITextView in which you add the content of the note, but when you scroll on the textView, the other cells that contain a textField and another control.
How can this be achieved? I know that Apple doesn't recommend a TextView inside a ScrollView, and doing it with table view it gets a bit weird with all the scrolling from the table and text view.
Here are some examples:
Any suggestions?
Thank you!
Firstly, They disabled text view scrolling and set its size to about screen size. Secondly, once text view's text is out of frame, expand it(calculate its size again).
So I found my problem, when I was setting the constraints for the content view (view inside scrollview) I set an Equal value for its height. To fix it I just made that relationship to Greater or Equal than... it now expands.
The other problem now is that when showing the keyboard it is not scrolling to the text I tap to. (The insets are properly setup though)
// MARK: Notififations from the Keyboard
func didShowKeyboard (notification: NSNotification) {
if momentTextView.isFirstResponder() {
if let keyboardSize = (notification.userInfo?[UIKeyboardFrameBeginUserInfoKey] as? NSValue)?.CGRectValue() {
scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: keyboardSize.size.height, right: 0)
scrollView.scrollIndicatorInsets = scrollView.contentInset
let caretPosition = momentTextView.caretRectForPosition(momentTextView.selectedTextRange!.start)
let newHeight = caretPosition.height * 1.5
let newCaretPosition = CGRect(x: caretPosition.origin.x, y: caretPosition.origin.y, width: caretPosition.width, height: newHeight)
scrollView.scrollRectToVisible(newCaretPosition, animated: true)
}
}
}
func willHideKeyboard (notification: NSNotification) {
if momentTextView.isFirstResponder() {
scrollView.contentInset = UIEdgeInsetsZero
scrollView.scrollIndicatorInsets = UIEdgeInsetsZero
}
}

iOS. UITextView. Text jumping after UIKeyboardDidHideNotification

I have editable UITextView and keyboard dismiss mode is interactive. Also my controller is listening two notifications: UIKeyboardWillShowNotification, UIKeyboardWillHideNotification.
func keyboardWillShow(notification: NSNotification) {
if let userInfo = notification.userInfo {
var insets = self.textView.contentInset;
let rect = userInfo[UIKeyboardFrameEndUserInfoKey]?.CGRectValue() ?? CGRectZero
insets.bottom = (rect.size.height - (CGRectGetHeight(self.view.frame) - CGRectGetMaxY(self.textView.frame)))
self.textView.contentInset = insets
self.textView.scrollIndicatorInsets = insets
}
}
func keyboardWillHide(notification: NSNotification) {
self.textView.contentInset = UIEdgeInsetsZero
self.textView.scrollIndicatorInsets = UIEdgeInsetsZero
}
This stuff works great, if text in UITextView doesn't contain any empty lines. If it do, contentOffset jumps to another, random place.
I'm not sure if this is a bug in iOS 7+, or I am doing something wrong.
If it's not a bug, how to get this going fluently without the jumping behaviour?
Thanks for your help.
I had been battling this exact same problem, when I would dismiss the keyboard the UITextView's content offset would jump back to {0, 0}. Interestingly, I only got this behavior on the device, but not in the simulator.
I originally tried to solve it by overriding UITextView's contentOffset method and having it just ignore {0, 0} values, and that was semi effective, until the content got too long, in which case it would just jump to a random offset, and set the same value 3 times (so it would set content offset to {0, 3605}, {0, 3605}, and {0, 3605} all in rapid succession).
After a long time spent looking for a solution, it turned out to be rather simple:
textview.layoutManager.allowsNonContiguousLayout = NO;
As discussed in this blog post. Hope that helps :)
I had 100% exactly the same problem as you and I also asked a question about it but no one could get it right. (I am the one who up voted and favourited your question!!)
I eventually did a workaround after 4 days of frustration. Just put the UITextView inside a UITableView (You don't need to put it inside a UITableViewCell, just drag to the UITableView then it's ok). Make your UITextView unscrollable.
The following method will make UITextView expand and update the UITableView every time it is changed. (Don't forget to connect UITextView's delegate)
func textViewDidChange(textView: UITextView) {
// Change textView height
self.textView.sizeToFit()
UIView.setAnimationsEnabled(false)
self.tableView.beginUpdates()
self.tableView.endUpdates()
UIView.setAnimationsEnabled(true)
}
The following method will make UITableView autoscroll to the cursor when UITextView becomes active.
func textViewDidBeginEditing(textView: UITextView) {
// Delay the following line so that it works properly
let delay = 0.005 * Double(NSEC_PER_SEC)
let time = dispatch_time(DISPATCH_TIME_NOW, Int64(delay))
dispatch_after(time, dispatch_get_main_queue()) {
var rect = self.textView.caretRectForPosition(self.textView.selectedTextRange?.end)
var changedRect = CGRectMake(rect.origin.x, rect.origin.y, rect.width, rect.height+3)
self.tableView.scrollRectToVisible(changedRect, animated: true)
}
}
You also need to change the UITableView contentInset and scrollIndicatorInsets in your keyboardWillShow and keyboardWillHide methods, depending on your screen layout.

Get UIScrollView to scroll to the top

How do I make a UIScrollView scroll to the top?
UPDATE FOR iOS 7
[self.scrollView setContentOffset:
CGPointMake(0, -self.scrollView.contentInset.top) animated:YES];
ORIGINAL
[self.scrollView setContentOffset:CGPointZero animated:YES];
or if you want to preserve the horizontal scroll position and just reset the vertical position:
[self.scrollView setContentOffset:CGPointMake(self.scrollView.contentOffset.x, 0)
animated:YES];
Here is a Swift extension that makes it easy:
extension UIScrollView {
func scrollToTop() {
let desiredOffset = CGPoint(x: 0, y: -contentInset.top)
setContentOffset(desiredOffset, animated: true)
}
}
Usage:
myScrollView.scrollToTop()
For Swift 4
scrollView.setContentOffset(.zero, animated: true)
iOS 11 and above
Try to play around with the new adjustedContentInset (It should even work with prefersLargeTitles, safe area etc.)
For example (scroll to the top):
var offset = CGPoint(
x: -scrollView.contentInset.left,
y: -scrollView.contentInset.top)
if #available(iOS 11.0, *) {
offset = CGPoint(
x: -scrollView.adjustedContentInset.left,
y: -scrollView.adjustedContentInset.top)
}
scrollView.setContentOffset(offset, animated: true)
Use setContentOffset:animated:
[scrollView setContentOffset:CGPointZero animated:YES];
Answer for Swift 2.0/3.0/4.0 and iOS 7+:
let desiredOffset = CGPoint(x: 0, y: -self.scrollView.contentInset.top)
self.scrollView.setContentOffset(desiredOffset, animated: true)
In iOS7 I had trouble getting a particular scrollview to go to the top, which worked in iOS6, and used this to set the scrollview to go to the top.
[self.myScroller scrollRectToVisible:CGRectMake(0, 0, 1, 1) animated:NO];
In SWIFT 5
Just set content Offset to zero
scrollView.setContentOffset(CGPoint.zero, animated: true)
Swift 3.0.1 version of rob mayoff's answer :
self.scrollView.setContentOffset(
CGPoint(x: 0,y: -self.scrollView.contentInset.top),
animated: true)
I think I have an answer that should be fully compatible with iOS 11 as well as prior versions (for vertical scrolling)
This takes into account the new adjustedContentInset and also accounts for the additional offset required when prefersLargeTitles is enabled on the navigationBar which appears to require an extra 52px offset on top of whatever the default is
This was a little tricky because the adjustedContentInset changes depending on the titleBar state (large title vs small title) so I needed to check and see what the titleBar height was and not apply the 52px offset if its already in the large state. Couldn't find any other method to check the state of the navigationBar so if anyone has a better option than seeing if the height is > 44.0 I'd like to hear it
func scrollToTop(_ scrollView: UIScrollView, animated: Bool = true) {
if #available(iOS 11.0, *) {
let expandedBar = (navigationController?.navigationBar.frame.height ?? 64.0 > 44.0)
let largeTitles = (navigationController?.navigationBar.prefersLargeTitles) ?? false
let offset: CGFloat = (largeTitles && !expandedBar) ? 52: 0
scrollView.setContentOffset(CGPoint(x: 0, y: -(scrollView.adjustedContentInset.top + offset)), animated: animated)
} else {
scrollView.setContentOffset(CGPoint(x: 0, y: -scrollView.contentInset.top), animated: animated)
}
}
Inspired by Jakub's solution
It's very common when your navigation bar overlaps the small portion of the scrollView content and it looks like content starts not from the top. For fixing it I did 2 things:
Size Inspector - Scroll View - Content Insets --> Change from Automatic to Never.
Size Inspector - Constraints- "Align Top to" (Top Alignment Constraints)- Second item --> Change from Superview.Top to Safe Area.Top and the value(constant field) set to 0
To fully replicate the status bar scrollToTop behavior we not only have to set the contentOffset but also want to make sure the scrollIndicators are displayed. Otherwise the user can quickly get lost.
The only public method to accomplish this is flashScrollIndicators. Unfortunately, calling it once after setting the contentOffset has no effect because it's reset immediately. I found it works when doing the flash each time in scrollViewDidScroll:.
// define arbitrary tag number in a global constants or in the .pch file
#define SCROLLVIEW_IS_SCROLLING_TO_TOP_TAG 19291
- (void)scrollContentToTop {
[self.scrollView setContentOffset:CGPointMake(self.scrollView.contentOffset.x, -self.scrollView.contentInset.top) animated:YES];
self.scrollView.tag = SCROLLVIEW_IS_SCROLLING_TO_TOP_TAG;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
self.scrollView.tag = 0;
});
}
In your UIScrollViewDelegate (or UITable/UICollectionViewDelegate) implement this:
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
if (scrollView.tag == SCROLLVIEW_IS_SCROLLING_TO_TOP_TAG) {
[scrollView flashScrollIndicators];
}
}
The hide delay is a bit shorter compared to the status bar scrollToTop behavior but it still looks nice.
Note that I'm abusing the view tag to communicate the "isScrollingToTop" state because I need this across view controllers. If you're using tags for something else you might want to replace this with an iVar or a property.
In modern iOS, set the the scroll view's content offset back to its top left adjustedContentInset:
let point = CGPoint(x: -scrollView.adjustedContentInset.left,
y: -scrollView.adjustedContentInset.top)
scrollView.setContentOffset(point, animated: true)
Scroll to top for UITableViewController, UICollectionViewController or any UIViewController having UIScrollView
extension UIViewController {
func scrollToTop(animated: Bool) {
if let tv = self as? UITableViewController {
tv.tableView.setContentOffset(CGPoint.zero, animated: animated)
} else if let cv = self as? UICollectionViewController{
cv.collectionView?.setContentOffset(CGPoint.zero, animated: animated)
} else {
for v in view.subviews {
if let sv = v as? UIScrollView {
sv.setContentOffset(CGPoint.zero, animated: animated)
}
}
}
}
}
iOS 16
For table and collection views, the following always works for me:
let top = CGRect(x: 0, y: 0, width: 1, height: 1)
tableView.scrollRectToVisible(top, animated: true)
collectionView.scrollRectToVisible(top, animated: true)
For scroll views:
let top = CGPoint(x: 0, y: -adjustedContentInset.top)
scrollView.setContentOffset(top, animated: animated)
adjustedContentInset returns the insets applied by the safe area (if any) and any custom insets applied after instantiation. If either safe or custom insets are applied, the content inset of the scroll view when it's at its top will be negative, not zero, which is why this property should be used.
iOS 2.0+
Mac Catalyst 13.0+
You can try: scrollView.scrollsToTop = true
You can refer it from documentation of developer.apple.com
I tried all the ways. But nothing worked for me. Finally I did like this.
I added self.view .addSubview(self.scroll) line of code in the viewDidLoad. After started setting up frame for scroll view and added components to scroll view.
It worked for me.
Make sure you added self.view .addSubview(self.scroll) line in the beginning. then you can add UI elements.

Resources