I would like to increase the height of a tableview whenever the keyboard disappears. I have a tableview that is populated from the bottom upwards. The tableview is not filled entirely with data, meaning there is some empty space at the top of the tableview. There will be some cells in the tableview. Since the table view is populated from the bottom upwards, these cells should be towards the bottom of the tableview. When the keyboard is shown, the total height of the tableview is about half of the screen. When the keyboard disappears, the total height of the tableview should be almost the whole screen. This means that when the keyboard disappears, the cells should start from further down on the screen.
This is what it looks like when there are two cells in the tableview:
This is what it looks like when two more cells have been added to the tableview:
This is what it looks like when the keyboard disappears:
When the keyboard disappears, the four cells should start from the bottom of the screen, not from halfway up the screen. The four cells should move downwards so that they start from where the gray line at the bottom of the screen is. There should not be a big gap between where the first cell (which says "Apple") is and where the gray line at the bottom of the screen is. At the same time, the tableView itself should be bigger. This means that while the top of the tableview remains in the same place, the bottom of the tableview is now lower than it was before, because the keyboard is now not taking up approximately half of the screen. How can I do this? Thanks.
Here is my code right now:
class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
#IBOutlet weak var textField: UITextField!
#IBOutlet weak var lowerGrayLine: UIView!
#IBOutlet weak var tableView: UITableView!
var fruits = [FruitModel]()
override func viewDidLoad() {
super.viewDidLoad()
//Adjust height of the lower Gray Line
lowerGrayLine.frame.origin.y = 411
//No dividing lines in the tableview initially
self.tableView.separatorStyle = .none
tableView.rowHeight = UITableViewAutomaticDimension
tableView.estimatedRowHeight = 140
if (self.tableView(self.tableView, numberOfRowsInSection: 0) > 0)
{
self.updateTableContentInset()
}
// Allow for keyboard dismissal
let tap: UITapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(ViewController.dismissKeyboard))
view.addGestureRecognizer(tap)
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)
// Show keyboard
textField.becomeFirstResponder()
}
func keyboardWillShow(notification: NSNotification) {
if let keyboardSize = (notification.userInfo?[UIKeyboardFrameBeginUserInfoKey] as? NSValue)?.cgRectValue {
//Adjust text box, and dividing line placement
textField.frame.origin.y = 416
lowerGrayLine.frame.origin.y = 411
}
}
func keyboardWillHide(notification: NSNotification) {
if let keyboardSize = (notification.userInfo?[UIKeyboardFrameBeginUserInfoKey] as? NSValue)?.cgRectValue {
textField.frame.origin.y = 597
lowerGrayLine.frame.origin.y = 557
}
}
//Calls this function when the tap is recognized.
func dismissKeyboard() {
//Causes the view (or one of its embedded text fields) to resign the first responder status.
view.endEditing(true)
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.fruits.count;
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cellIdentifier = "fruitCell"
guard let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) as? FruitTableViewCell else {
fatalError("The dequeued cell is not an instance of FruitTableViewCell.")
}
var fruitName = fruits[indexPath.row]
var fruitNamename = fruitName.name
var colon = ":"
cell.nameLabel.text = fruitNamename + colon
cell.fruitLabel.text = fruitName.fruit
//Make some adjustments to make a line appear when a fruitCell is actually being shown
cell.preservesSuperviewLayoutMargins = false
cell.separatorInset = UIEdgeInsets.zero
cell.layoutMargins = UIEdgeInsets.zero
// This causes the bottom-most cell to not have a cell separator
if (indexPath.row == fruits.count-1) {
cell.separatorInset = UIEdgeInsetsMake(0, cell.bounds.size.width, 0, 0);
}
return cell
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return UITableViewAutomaticDimension
}
// Adjust the tableView
func updateTableContentInset() {
let numRows = tableView(tableView, numberOfRowsInSection: 0)
var contentInsetTop = tableView.bounds.size.height
for i in 0..<numRows {
contentInsetTop -= tableView(tableView, heightForRowAt: IndexPath(item: i, section: 0))
if contentInsetTop <= 0 {
contentInsetTop = 0
}
}
self.tableView.contentInset = UIEdgeInsetsMake(contentInsetTop, 0, 0, 0)
self.tableView.scrollToRow(at: IndexPath(item: numRows-1, section: 0), at: .bottom, animated: false)
//Prevent scrolling
self.tableView.bounces = false;
//When we have actual fruit Cells to show, we want the line divisions
self.tableView.separatorStyle = .singleLine
}
}
I have tried adding the following code to my keyboardWillShow function:
var tableViewFrame: CGRect!
tableViewFrame = self.tableView.frame
tableViewFrame.size.height += 146.0
tableView.frame = tableViewFrame
tableView.contentInset = UIEdgeInsetsMake(146.0, 0, 0, 0);
However, when the keyboard disappears, there are horizontal gray lines that span the screen, as shown below:
EDIT:
These horizontal gray lines (six in total) should not be there. How do I prevent them from appearing when the keyboard has disappeared?
The simplest solution is IQKeyboardManager. This will manage the space below keyboard for you.
Otherwise, add a bottom constraint tableViewBottomConstraint to the table view. Set its initial value tableViewBottomConstraint.constant = 0
Now on set it equal to the keyboard height + padding in keyboardWillShow keyboardWillHide
func keyboardWillShow(notification: NSNotification) {
tableViewBottomConstraint.constant = keyboardHeight + padding
layoutTableView()
}
func keyboardWillHide(notification: NSNotification) {
tableViewBottomConstraint.constant = 0
layoutTableView()
}
private func layoutTableView() {
UIView.animate(withDuration: 0.3, animations: {
tableView.superview?.layoutIfNeeded()
}, completion: nil)
}
To do this you need to write your code in keyboardWillHide:
Resize the tableView increasing its frame's height (or the relative constraint's constant value) of the keyboard's height;
Key point: set tableView's top content inset equal to the keyboard's height.
So for instance if the keyboard's height is 216pt, you will do:
CGRect tableViewFrame = tableView.frame;
tableViewFrame.size.height += 216.0;
tableView.frame = tableViewFrame;
tableView.contentInset = UIEdgeInsetsMake(216.0, 0, 0, 0);
If you are using constraints instead, the only difference is that you'll change the height of the constraint's constant relative to the height of the tableView.
P.s. if you want a deeper explanation of what UIEdgeInsets is, take a look here: https://developer.apple.com/documentation/uikit/uiedgeinsets
They have been made exactly to create empty space with a specific offset.
func keyboardWillShow(notification:NSNotification) {
if let keyboardSize = notification.userInfo?[UIKeyboardFrameBeginUserInfoKey] as? CGRect {
let contentInsets = UIEdgeInsets(top: 0, left: 0, bottom:
keyboardSize.height, right: 0)
print(contentInsets)
btnBottomConstrain.constant = contentInsets.bottom
UIView.animate(withDuration: 0.2, delay: 0, options: .transitionCurlDown, animations: {
self.view.layoutIfNeeded()
}, completion: nil)
}
}
func keyboardWillHide(notification:NSNotification) {
if let keyboardSize = notification.userInfo?[UIKeyboardFrameBeginUserInfoKey] as? CGRect {
let contentInsets = UIEdgeInsets(top: 0, left: 0, bottom: keyboardSize.height, right: 0)
print(contentInsets)
self.btnBottomConstrain.constant = 0
UIView.animate(withDuration: 0.2, delay: 0, options: .transitionCurlDown, animations: {
self.view.layoutIfNeeded()
}, completion: nil)
}
}
replace these two methods with your keyboardshow and keybordhide methods accordingly. here btnBottomConstrain will be the outlet of your tableview's bottom constraint. I hope this will work for you.
Related
I have a view controller that contains a uicollectionview. Each collectionview cell contains a button that, when clicked, adds a new label within the cell. To expand the height of each cell I call reloadItems(at: [indexPath]).
Unfortunately calling reloadItems(at: [indexPath]) fades out the old label and fades in the new label, how do I prevent any labels from fading out?
The bug becomes even more apparent every time I click the addLabel button: a new label fades in but whatever previous labels had not been visible suddenly appear again and whatever labels used to be visible, magically turn invisible again.
reloadItems(at: [indexPath]) seems to toggle the alpha of each new label differently. I would like to resize and add new labels to the cell without having any labels disappear.
Here is my code:
ViewController
class ViewController: UIViewController {
weak var collectionView: UICollectionView!
var expandedCellIdentifier = "ExpandableCell"
var cellWidth:CGFloat{
return collectionView.frame.size.width
}
var expandedHeight : CGFloat = 200
var notExpandedHeight : CGFloat = 50
//the first Int gives the row, the second Int gives the amount of labels in the row
var isExpanded = [Int:Int]()
override func viewDidLoad() {
super.viewDidLoad()
for i in 0..<4 {
isExpanded[i] = 1
}
}
}
extension ViewController:UICollectionViewDataSource{
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return isExpanded.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: expandedCellIdentifier, for: indexPath) as! ExpandableCell
cell.indexPath = indexPath
cell.delegate = self
cell.setupCell = "true"
return cell
}
}
extension ViewController:UICollectionViewDelegateFlowLayout{
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
if isExpanded[indexPath.row]! > 1{
let height = (collectionView.frame.width/10)
let newHeight = height * CGFloat(isExpanded[indexPath.row]!)
return CGSize(width: cellWidth, height: newHeight)
}else{
return CGSize(width: cellWidth, height: collectionView.frame.width/6 )
}
}
}
extension ViewController:ExpandedCellDeleg{
func topButtonTouched(indexPath: IndexPath) {
isExpanded[indexPath.row] = isExpanded[indexPath.row]! + 1
UIView.animate(withDuration: 0.8, delay: 0.0, usingSpringWithDamping: 0.9, initialSpringVelocity: 0.9, options: UIView.AnimationOptions.curveEaseInOut, animations: {
self.collectionView.reloadItems(at: [indexPath])
}, completion: { success in
print("success")
})
}
}
Protocol
protocol ExpandedCellDeleg:NSObjectProtocol{
func topButtonTouched(indexPath:IndexPath)
}
ExpandableCell
class ExpandableCell: UICollectionViewCell {
weak var delegate:ExpandedCellDeleg?
public var amountOfIntervals:Int = 1
public var indexPath:IndexPath!
var setupCell: String? {
didSet {
print("cell should be setup!!")
}
}
let ivAddLabel: UIImageView = {
let imageView = UIImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.image = #imageLiteral(resourceName: "plus")
imageView.tintColor = .black
imageView.contentMode = .scaleToFill
imageView.backgroundColor = UIColor.clear
return imageView
}()
override init(frame: CGRect) {
super.init(frame: .zero)
contentView.addSubview(ivAddLabel)
let name = UILabel(frame: CGRect(x: 0, y: 0, width: 100, height: 18))
name.center = CGPoint(x: Int(frame.width)/2 , y: 20)
name.textAlignment = .center
name.font = UIFont.systemFont(ofSize: 16)
name.textColor = UIColor.black
name.text = "Fred"
contentView.addSubview(name)
ivAddLabel.rightAnchor.constraint(equalTo: self.rightAnchor, constant: -14).isActive = true
ivAddLabel.topAnchor.constraint(equalTo: self.topAnchor, constant: 10).isActive = true
ivAddLabel.widthAnchor.constraint(equalToConstant: 20).isActive = true
ivAddLabel.heightAnchor.constraint(equalToConstant: 20).isActive = true
ivAddLabel.layer.masksToBounds = true
ivAddLabel.isUserInteractionEnabled = true
let addGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(ivAddLabelSelected))
ivAddLabel.addGestureRecognizer(addGestureRecognizer)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
#objc func ivAddLabelSelected(){
print("add button was tapped!")
if let delegate = self.delegate{
amountOfIntervals = amountOfIntervals + 1
let height = (20*amountOfIntervals)
let name = UILabel(frame: CGRect(x: 0, y: 0, width: 100, height: 18))
name.center = CGPoint(x: Int(frame.width)/2, y: height)
name.textAlignment = .center
name.font = UIFont.systemFont(ofSize: 16)
name.textColor = UIColor.black
name.text = "newFred"
name.alpha = 0.0
contentView.addSubview(name)
UIView.animate(withDuration: 0.2, animations: { name.alpha = 1.0 })
delegate.topButtonTouched(indexPath: indexPath)
}
}
}
It's because you animate the new label
UIView.animate(withDuration: 0.2, animations: { name.alpha = 1.0 })
and in parallel reload the cell which creates a new cell/reuses existing and shows it, but also you wrap the reload into animation block which seems strange and useless:
UIView.animate(withDuration: 0.8, delay: 0.0, usingSpringWithDamping: 0.9, initialSpringVelocity: 0.9, options: UIView.AnimationOptions.curveEaseInOut, animations: {
self.collectionView.reloadItems(at: [indexPath])
}, completion: { success in
print("success")
})
You need to remove both animations and just reload the cell. If you need a nice animation of cell expansion you need to implement collection layout which will handle all states - start, intermediate, end of the animation. It's hard.
Try to use suggested in other answer "UICollectionView Self Sizing Cells with Auto Layout" if it will not help, then either forgot the idea of animation or implement custom layout.
I'd suggest you read into self-sizing UICollectionViewCells (e.g. UICollectionView Self Sizing Cells with Auto Layout) and UIStackView (e.g. https://janthielemann.de/ios-development/self-sizing-uicollectionviewcells-ios-10-swift-3/).
You should use a UIStackView, with constraints to top and bottom edge of your cells contentView.
Then you can add your Labels as managedSubviews to your stackView. This will add the labels with animation.
With self-sizing cell you do not need to reloadItems and it should work as you expect.
I would like to recalculate the height of a table view's footer based upon the table view's changing content size. When the table has zero rows the height of the footer will be at its maximum. As rows are added to the table the footer's height will be reduced until it reaches a minimum. What I am doing is using the footer to fill up the empty space that appears at the bottom of the table when there are zero or few rows. In addition to rows being added it is possible for the content size to change because the height (content) of an existing row has been changed.
Supposing that I have a view controller whose main view contains two subviews: a button and a table view. Clicking the button results in the data store being modified and the table's reloadData method being called. When/Where would I assign a new value to the table's tableFooterView.bounds.size.height?
I should also point out that I am using UITableViewAutomaticDimension. If, in the table's data source delegate method cellForRowAt, I print the cell heights I get:
Upper table cell height = 21.0
Upper table cell height = 21.0
Upper table cell height = 21.0
Upper table cell height = 21.0
Upper table cell height = 44.0
All 21 except for the last one, the new one. This must be due to the automatic dimensioning not yet having been applied.
Update:
I have tentatively arrived at the following solution (many thanks to all of the folks on this thread for the biggest part of the solution). I am tentative because the solution involves calling reloadData twice in order to deal with an issue with the contentSize. See this GitHub project for a demo of the contentSize issue.
class TableView: UITableView {
override func reloadData() {
execute() { super.reloadData() }
}
override func reloadRows(at indexPaths: [IndexPath], with animation: UITableView.RowAnimation) {
execute() { super.reloadRows(at: indexPaths, with: animation) }
}
private func execute(reload: #escaping () -> Void) {
CATransaction.begin()
CATransaction.setCompletionBlock() {
if self.adjustFooter() {
reload() // Cause the contentSize to update (see GitHub project)
self.layoutIfNeeded()
}
}
reload()
CATransaction.commit()
}
// Return true(false) if the footer was(was not) adjusted
func adjustFooter() -> Bool {
guard let currentFrame = tableFooterView?.frame else { return false }
let newHeight = calcFooterHeight()
let adjustmentNeeded = newHeight != currentFrame.height
if adjustmentNeeded {
tableFooterView?.frame = CGRect(x: currentFrame.minX, y: currentFrame.minY, width: currentFrame.width, height: newHeight)
}
return adjustmentNeeded
}
private let minFooterHeight: CGFloat = 44
private func calcFooterHeight() -> CGFloat {
guard let footerView = tableFooterView else { return 0 }
let spaceTaken = contentSize.height - footerView.bounds.height
let spaceAvailable = bounds.height - spaceTaken
return spaceAvailable > minFooterHeight ? spaceAvailable : minFooterHeight
}
}
UITableViewDelegate has method tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat which we can use to specifiy height of section footers. This method fires when we call reloadData() for table view or when screen orientation was changed, etc.
So you can implement this method to calculate a new height of the footer:
override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
guard section == 0 else { return 0.0 } // assume there is only one section in the table
var cellsHeight: CGFloat = 0.0
let rows = self.tableView(tableView, numberOfRowsInSection: section)
for row in 0..<rows
{
let indexPath = IndexPath(item: row, section: section)
cellsHeight += self.tableView(tableView, heightForRowAt: indexPath)
}
let headerHeight: CGFloat = tableView.tableHeaderView?.frame.height ?? 0.0
let footerHeight = view.frame.height - headerHeight - cellsHeight
return footerHeight
}
I arrived at the following solution. Many thanks to all of the folks on this thread for the biggest part of the solution. The TableViewController.TableView class provides the desired functionality. The remainder of the code fleshes out a complete example.
//
// TableViewController.swift
// Tables
//
// Created by Robert Vaessen on 11/6/18.
// Copyright © 2018 Robert Vaessen. All rights reserved.
//
// Note: Add the following to AppDelegate:
//
// func application(_ application: UIApplication,
// didFinishLaunchingWithOptions launchOptions:
// [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// window = UIWindow(frame: UIScreen.main.bounds)
// window?.makeKeyAndVisible()
// window?.rootViewController = TableViewController()
// return true
// }
import UIKit
class TableViewController: UIViewController {
class TableView : UITableView {
override func reloadData() {
execute() { super.reloadData() }
}
override func reloadRows(at indexPaths: [IndexPath], with animation: UITableView.RowAnimation) {
execute() { super.reloadRows(at: indexPaths, with: animation) }
}
private func execute(reload: #escaping () -> Void) {
CATransaction.begin()
CATransaction.setCompletionBlock() {
print("Reload completed")
_ = self.adjustFooter()
}
print("\nReload begun")
reload()
CATransaction.commit()
}
private func adjustFooter() -> Bool {
guard let footerView = tableFooterView else { return false }
func calcFooterHeight() -> CGFloat {
var heightUsed = tableHeaderView?.bounds.height ?? 0
for cell in visibleCells { heightUsed += cell.bounds.height }
let heightRemaining = bounds.height - heightUsed
let minHeight: CGFloat = 44
return heightRemaining > minHeight ? heightRemaining : minHeight
}
let newHeight = calcFooterHeight()
guard newHeight != footerView.bounds.height else { return false }
// Keep the origin where it is, i.e. tweaking just the height expands the frame about its center
let currentFrame = footerView.frame
footerView.frame = CGRect(x: currentFrame.origin.x, y: currentFrame.origin.y, width: currentFrame.width, height: newHeight)
return true
}
}
class FooterView : UIView {
override func draw(_ rect: CGRect) {
print("Drawing footer")
super.draw(rect)
}
}
private var tableView: TableView!
private let cellReuseId = "TableCell"
private let data: [UIColor] = [UIColor(white: 0.4, alpha: 1), UIColor(white: 0.5, alpha: 1), UIColor(white: 0.6, alpha: 1), UIColor(white: 0.7, alpha: 1)]
private var dataRepeatCount = 1
override func viewDidLoad() {
super.viewDidLoad()
func createTable(in: UIView) -> TableView {
let tableView = TableView(frame: CGRect.zero)
tableView.separatorStyle = .none
tableView.translatesAutoresizingMaskIntoConstraints = false
`in`.addSubview(tableView)
tableView.centerXAnchor.constraint(equalTo: `in`.centerXAnchor).isActive = true
tableView.centerYAnchor.constraint(equalTo: `in`.centerYAnchor).isActive = true
tableView.widthAnchor.constraint(equalTo: `in`.widthAnchor, multiplier: 1).isActive = true
tableView.heightAnchor.constraint(equalTo: `in`.heightAnchor, multiplier: 0.8).isActive = true
tableView.dataSource = self
tableView.register(UITableViewCell.self, forCellReuseIdentifier: cellReuseId)
return tableView
}
func addHeader(to: UITableView) {
let header = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: 50))
to.tableHeaderView = header
let color = UIColor.black
let offset: CGFloat = 64
let add = UIButton(type: .system)
add.setTitle("Add", for: .normal)
add.layer.borderColor = color.cgColor
add.layer.borderWidth = 1
add.layer.cornerRadius = 5
add.tintColor = color
add.contentEdgeInsets = UIEdgeInsets.init(top: 8, left: 8, bottom: 8, right: 8)
add.addTarget(self, action: #selector(addRows), for: .touchUpInside)
add.translatesAutoresizingMaskIntoConstraints = false
header.addSubview(add)
add.centerXAnchor.constraint(equalTo: to.centerXAnchor, constant: -offset).isActive = true
add.centerYAnchor.constraint(equalTo: header.centerYAnchor).isActive = true
let remove = UIButton(type: .system)
remove.setTitle("Remove", for: .normal)
remove.layer.borderColor = color.cgColor
remove.layer.borderWidth = 1
remove.layer.cornerRadius = 5
remove.tintColor = color
remove.contentEdgeInsets = UIEdgeInsets.init(top: 8, left: 8, bottom: 8, right: 8)
remove.addTarget(self, action: #selector(removeRows), for: .touchUpInside)
remove.translatesAutoresizingMaskIntoConstraints = false
header.addSubview(remove)
remove.centerXAnchor.constraint(equalTo: header.centerXAnchor, constant: offset).isActive = true
remove.centerYAnchor.constraint(equalTo: header.centerYAnchor).isActive = true
}
func addFooter(to: UITableView) {
let footer = FooterView(frame: CGRect(x: 0, y: 0, width: 0, height: 50))
footer.layer.borderWidth = 3
footer.layer.borderColor = UIColor.red.cgColor
//footer.contentMode = .redraw
to.tableFooterView = footer
}
tableView = createTable(in: view)
addHeader(to: tableView)
addFooter(to: tableView)
view.backgroundColor = .white
tableView.backgroundColor = .black // UIColor(white: 0.2, alpha: 1)
tableView.tableHeaderView!.backgroundColor = .cyan // UIColor(white: 0, alpha: 1)
tableView.tableFooterView!.backgroundColor = .white // UIColor(white: 0, alpha: 1)
}
#objc private func addRows() {
dataRepeatCount += 1
tableView.reloadData()
}
#objc private func removeRows() {
dataRepeatCount -= dataRepeatCount > 0 ? 1 : 0
tableView.reloadData()
}
}
extension TableViewController : UITableViewDataSource {
func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
guard section == 0 else { fatalError("Unexpected section: \(section)") }
return dataRepeatCount * data.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
cell.textLabel?.textAlignment = .center
cell.backgroundColor = data[indexPath.row % data.count]
cell.textLabel?.textColor = .white
cell.textLabel?.text = "\(indexPath.row)"
return cell
}
}
Apps like Apple's maps app or Google maps use scrollable bottom sheet overlays to present additional content. While this behavior is not too difficult to rebuild, I struggle to implement one important feature:
When there is a scroll view embedded inside the bottom sheet, then the user can scroll it to the top but then – instead of bouncing off at the top – the bottom sheet starts scrolling down instead of the table view.
Here's an example video of what I mean:
Example Video:
This is a nice user experience as there is no interruption in the scrolling and it's what I expect as a user: It's as if once the content scroll view has reached its top the gesture receiver is automatically handed over the super scroll view.
In order to achieve this behavior, I see three different approaches:
I track the content scroll view's contentOffset in the scroll view's scrollViewDidScroll(_:) delegate method. Then I do
if contentScrollView.contentOffset.y < 0 {
contentScrollView.contentOffset.y = 0
}
to keep the content scroll view from scrolling above the top of its content. Instead, I pass the y distance that it would have scrolled to the super scroll view which scrolls the whole bottom sheet.
I find a way to change the receiver of the scrolling (pan) gesture recognizer from the content scroll view to the super scroll view as soon as the content scroll view has scrolled to its top.
I handle everything inside the super scroll view. It asks its content view controller through a delegate protocol if it wants to handle the touches and only if it doesn't (because its content scroll view has reached the top) the super scroll view scrolls by itself.
While I have managed to implement the first variant (it's what you see in the video), I'd strongly prefer to use approach 2 or 3. It's a much cleaner way to have the view controller that controls the bottom sheet manage all the scrolling logic without exposing its internals.
Unfortunately, I haven't found a way to somehow split the pan gesture into two components (one that controls the receiver scroll view and one that controls another scroll view)
Any ideas on how to achieve this kind of behavior?
I am very interested in this question and I hope by providing how I would implement it, it does not stifle an answer that might show how to truly pass around the responder. The trick I think which I put in the comments is keeping track of the touches. I forgot about how scrollview gobbles those up but you can use a UIPanGesture. See if this is close to what you are looking for. The only case I ran into that might take more thought is using the scroll to dismiss the bottom view. Most of this code is setup to get a working scrollview in the view. I think property animations might be best to make it interruptible or even my personal fav Facebook Pop animations. To keep it simple I just used UIView animations. Let me know if this solves what you are looking for. The code is below and here is the result
. The scrollview remains scrollable and active. I animate the frames but updating constraints could work as well.
import UIKit
class ViewController: UIViewController{
//setup
var items : [Int] = []
lazy var tableView : UITableView = {
let tv = UITableView(frame: CGRect(x: 0, y: topViewHeight, width: self.view.frame.width, height: self.view.frame.height))
tv.autoresizingMask = [.flexibleWidth,.flexibleHeight]
tv.delegate = self
tv.dataSource = self
tv.layer.cornerRadius = 4
return tv
}()
lazy var topView : UIView = {
let v = UIView(frame: CGRect(x: 0, y: 0, width: self.view.frame.width, height: topViewHeight))
v.backgroundColor = .green
v.autoresizingMask = [.flexibleWidth,.flexibleHeight]
return v
}()
let cellIdentifier = "ourCell"
//for animation
var isAnimating = false
var lastOffset : CGPoint = .zero
var startingTouch : CGPoint?
let topViewHeight : CGFloat = 500
var isShowing : Bool = false
let maxCollapse : CGFloat = 50
override func viewDidLoad() {
super.viewDidLoad()
for x in 0...100{
items.append(x)
}
// Do any additional setup after loading the view, typically from a nib.
self.view.addSubview(topView)
self.view.addSubview(tableView)
self.tableView.reloadData()
let pan = UIPanGestureRecognizer(target: self, action: #selector(moveFunction(pan:)))
pan.delegate = self
self.view.addGestureRecognizer(pan)
}
#objc func moveFunction(pan:UIPanGestureRecognizer) {
let point:CGPoint = pan.location(in: self.view)
switch pan.state {
case .began:
startingTouch = point
break
case .changed:
processMove(touchPoint:point.y)
break
default:
processEnding(currentPointY: point.y)
break
}
}
}
extension ViewController : UITableViewDelegate,UITableViewDataSource {
func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return items.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
var cell : UITableViewCell!
cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier)
if cell == nil {
cell = UITableViewCell(style: .default, reuseIdentifier: cellIdentifier)
}
cell.textLabel?.text = "\(items[indexPath.row])"
return cell
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return 30
}
}
extension ViewController : UIScrollViewDelegate{
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if isAnimating == true{
scrollView.contentOffset = lastOffset
return
}
lastOffset = scrollView.contentOffset
}
}
extension ViewController : UIGestureRecognizerDelegate{
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
}
extension ViewController{
func processMove(touchPoint:CGFloat){
if let start = startingTouch{
if touchPoint <= topViewHeight && start.y > topViewHeight{
isAnimating = true
tableView.frame = CGRect(x: 0, y:touchPoint, width: self.view.frame.width, height: self.view.frame.height)
return
}else if touchPoint >= self.maxCollapse && isShowing == true && start.y < self.maxCollapse{
isAnimating = true
tableView.frame = CGRect(x: 0, y:touchPoint, width: self.view.frame.width, height: self.view.frame.height)
return
}else if isShowing == true && self.tableView.contentOffset.y <= 0{
//this is the only one i am slightly unsure about
isAnimating = true
tableView.frame = CGRect(x: 0, y:touchPoint, width: self.view.frame.width, height: self.view.frame.height)
return
}
}
self.isAnimating = false
}
func processEnding(currentPointY:CGFloat){
startingTouch = nil
if isAnimating{
if currentPointY < topViewHeight/2{
UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.9, initialSpringVelocity: 0.0, options: .curveEaseInOut, animations: {
self.tableView.frame = CGRect(x: 0, y:self.maxCollapse, width: self.view.frame.width, height: self.view.frame.height)
}) { (finished) in
self.isShowing = true
}
}else{
UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.9, initialSpringVelocity: 0.0, options: .curveEaseInOut, animations: {
self.tableView.frame = CGRect(x: 0, y:self.topViewHeight, width: self.view.frame.width, height: self.view.frame.height)
}) { (finished) in
self.isShowing = false
}
}
}
self.isAnimating = false
}
}
I have a tableView.tableHeaderView which is a UITableViewHeaderFooterView.
I would like this header to initially not be displayed when the tableViewController is first presented. When the user scrolls down from the top of the tableView, I would like to present the header.
Then when the user slightly scrolls down, I want the header to snap to a hidden position. The behaviors is exactly like the archived chats header of the WhatsApp page where all your chat's are listed.
Is there any way to achieve this without a complex set of scrollview delegate calls?
I thought on previous versions of swift/xcode, the tableView.tableHeaderView kind of snapped up and down but I notice it's not doing that anymore.
I think the only solution might be overriding the scrollViewDidScroll. This is what I've done but when the tableView.headerView reappears, it does so over the first cell of the tableView. Not sure how to make it appear in the correct position.
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
if let myHeaderView = self.tableView.tableHeaderView as? MyTableViewHeaderFooterView {
let height = (self.navigationController?.navigationBar.frame.size.height)! + UIApplication.shared.statusBarFrame.size.height + self.searchController.searchBar.frame.size.height
if scrollView.contentOffset.y < -height {
UIView.animate(withDuration: 0.1) {
myHeaderView.frame.size.height = 44
}
}
}
}
This is what I've settled on which seems to work pretty well in all cases:
var minimumTableViewInset = CGFloat(88) // this can be adjusted to 32 when the tableView is in landscape. need observer for rotation to set that.
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(UINib(nibName: "MyTableViewHeaderFooterView", bundle: nil), forHeaderFooterViewReuseIdentifier: "MyTableViewHeaderFooterView")
let listsHeader = tableView.dequeueReusableHeaderFooterView(withIdentifier: "MyTableViewHeaderFooterView") as! MyTableViewHeaderFooterView
listsHeader.alpha = 0
listsHeader.frame.size.height = 0
self.tableView.tableHeaderView = listsHeader
}
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
if let headerView = self.tableView.tableHeaderView as? MyTableViewHeaderFooterView {
let height = (self.navigationController?.navigationBar.frame.size.height)! + UIApplication.shared.statusBarFrame.size.height + self.searchController.searchBar.frame.size.height
if scrollView.contentOffset.y < -height {
if headerView.frame.size.height != 44 {
tableView.beginUpdates()
headerView.frame.size.height = 44
tableView.endUpdates()
UIView.animate(withDuration: 0.5) {
self.tableView.tableHeaderView?.alpha = 1
}
}
}
}
}
override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if let headerView = self.tableView.tableHeaderView as? MyTableViewHeaderFooterView {
if scrollView.contentOffset.y > -(minimumTableViewInset - CGFloat(10)) {
if headerView.frame.size.height != 0 {
tableView.beginUpdates()
headerView.frame.size.height = 0
tableView.endUpdates()
UIView.animate(withDuration: 0.2) {
self.tableView.tableHeaderView?.alpha = 0
}
tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: UITableViewScrollPosition.bottom, animated: true)
}
}
}
}
override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if let headerView = self.tableView.tableHeaderView as? MyTableViewHeaderFooterView {
if scrollView.contentOffset.y > -(minimumTableViewInset - CGFloat(10)) {
if headerView.frame.size.height != 0 {
tableView.beginUpdates()
headerView.frame.size.height = 0
tableView.endUpdates()
UIView.animate(withDuration: 0.2) {
self.tableView.tableHeaderView?.alpha = 0
}
tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: UITableViewScrollPosition.bottom, animated: true)
}
}
}
}
I am writing from scratch growing UITextView in my swift app.
I put a textView on the view like this:
it is right above the keyboard.
The textView has constraints attached to the view: leading, bottom, top and trailing, all equals = 4.
The view has the following constraints:
trailing, leading, bottom, top and height
Height is an outlet in my code. I'm checking how many lines are in the textView and based on that I'm modifying height:
func textViewDidChange(textView: UITextView) { //Handle the text changes here
switch(textView.numberOfLines()) {
case 1:
heightConstraint.constant = 38
break
case 2:
heightConstraint.constant = 50
break
case 3:
heightConstraint.constant = 70
break
case 4:
heightConstraint.constant = 90
break
default:
heightConstraint.constant = 90
break
}
}
The number of lines above is calculated by this extension:
extension UITextView{
func numberOfLines() -> Int{
if let fontUnwrapped = self.font{
return Int(self.contentSize.height / fontUnwrapped.lineHeight)
}
return 0
}
}
The initial height of the textView is 38.
The initial font size in the textView is 15.
Now, it works nice, when user starts typing new line, but the textView is not set within full bounds of the view. I mean by that the fact, that it looks like this:
and it should look like this:
Why there is this extra white space being added and how can I get rid of it?
Currently when new line appears there's this white space, but when user scrolls the textView to center the text and get rid of the white space - it is gone forever, user is not able to scroll it up again so the white line is there. So for me it looks like some problem with refreshing content, but maybe you know better - can you give me some hints?
Here is a bit different approach I use in the comment section of one of the apps I'm developing. This works very similar to Facebook Messenger iOS app's input field. Changed outlet names to match with the ones in your question.
//Height constraint outlet of view which contains textView.
#IBOutlet weak var heightConstraint: NSLayoutConstraint!
#IBOutlet weak var textView: UITextView!
//Maximum number of lines to grow textView before enabling scrolling.
let maxTextViewLines = 5
//Minimum height for textViewContainer (when there is no text etc.)
let minTextViewContainerHeight = 40
func textViewDidChange(textView: UITextView) {
let textViewVerticalInset = textView.textContainerInset.bottom + textView.textContainerInset.top
let maxHeight = ((textView.font?.lineHeight)! * maxTextViewLines) + textViewVerticalInset
let sizeThatFits = textView.sizeThatFits(CGSizeMake(textView.frame.size.width, CGFloat.max))
if sizeThatFits.height < minTextViewContainerHeight {
heightConstraint.constant = minTextViewContainerHeight
textView.scrollEnabled = false
} else if sizeThatFits.height < maxHeight {
heightConstraint.constant = sizeThatFits.height
textView.scrollEnabled = false
} else {
heightConstraint.constant = maxHeight
textView.scrollEnabled = true
}
}
func textViewDidEndEditing(textView: UITextView) {
textView.text = ""
heightConstraint.constant = minTextViewContainerHeight
textView.scrollEnabled = false
}
I'm using ASTextInputAccessoryView. It handles everything for you and is very easy to set up:
import ASTextInputAccessoryView
class ViewController: UIViewController {
var iaView: ASResizeableInputAccessoryView!
var messageView = ASTextComponentView()
override func viewDidLoad() {
super.viewDidLoad()
let photoComponent = UINib
.init(nibName: "PhotosComponentView", bundle: nil)
.instantiateWithOwner(self, options: nil)
.first as! PhotosComponentView
messageView = ASTextComponentView(frame: CGRect(x: 0, y: 0, width: screenSize.width , height: 44))
messageView.backgroundColor = UIColor.uIColorFromHex(0x191919)
messageView.defaultSendButton.addTarget(self, action: #selector(buttonAction), forControlEvents: .TouchUpInside)
iaView = ASResizeableInputAccessoryView(components: [messageView, photoComponent])
iaView.delegate = self
}
}
//MARK: Input Accessory View
extension ViewController {
override var inputAccessoryView: UIView? {
return iaView
}
// IMPORTANT Allows input view to stay visible
override func canBecomeFirstResponder() -> Bool {
return true
}
// Handle Rotation
override func viewWillTransitionToSize(size: CGSize, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransitionToSize(size, withTransitionCoordinator: coordinator)
coordinator.animateAlongsideTransition({ (context) in
self.messageView.textView.layoutIfNeeded()
}) { (context) in
self.iaView.reloadHeight()
}
}
}
// MARK: ASResizeableInputAccessoryViewDelegate
extension ViewController: ASResizeableInputAccessoryViewDelegate {
func updateInsets(bottom: CGFloat) {
var contentInset = tableView.contentInset
contentInset.bottom = bottom
tableView.contentInset = contentInset
tableView.scrollIndicatorInsets = contentInset
}
func inputAccessoryViewWillAnimateToHeight(view: ASResizeableInputAccessoryView, height: CGFloat, keyboardHeight: CGFloat) -> (() -> Void)? {
return { [weak self] in
self?.updateInsets(keyboardHeight)
self?.tableView.scrollToBottomContent(false)
}
}
func inputAccessoryViewKeyboardWillPresent(view: ASResizeableInputAccessoryView, height: CGFloat) -> (() -> Void)? {
return { [weak self] in
self?.updateInsets(height)
self?.tableView.scrollToBottomContent(false)
}
}
func inputAccessoryViewKeyboardWillDismiss(view: ASResizeableInputAccessoryView, notification: NSNotification) -> (() -> Void)? {
return { [weak self] in
self?.updateInsets(view.frame.size.height)
}
}
func inputAccessoryViewKeyboardDidChangeHeight(view: ASResizeableInputAccessoryView, height: CGFloat) {
let shouldScroll = tableView.isScrolledToBottom
updateInsets(height)
if shouldScroll {
self.tableView.scrollToBottomContent(false)
}
}
}
Now you just need to set up the actions for the buttons of the AccessoryView.
// MARK: Actions
extension ViewController {
func buttonAction(sender: UIButton!) {
// do whatever you like with the "send" button. for example post stuff to firebase or whatever
// messageView.textView.text <- this is the String inside the textField
messageView.textView.text = ""
}
#IBAction func dismissKeyboard(sender: AnyObject) {
self.messageView.textView.resignFirstResponder()
}
func addCameraButton() {
let cameraButton = UIButton(type: .Custom)
let image = UIImage(named: "camera")?.imageWithRenderingMode(.AlwaysTemplate)
cameraButton.setImage(image, forState: .Normal)
cameraButton.tintColor = UIColor.grayColor()
messageView.leftButton = cameraButton
let width = NSLayoutConstraint(
item: cameraButton,
attribute: .Width,
relatedBy: .Equal,
toItem: nil,
attribute: .NotAnAttribute,
multiplier: 1,
constant: 40
)
cameraButton.superview?.addConstraint(width)
cameraButton.addTarget(self, action: #selector(self.showPictures), forControlEvents: .TouchUpInside)
}
func showPictures() {
PHPhotoLibrary.requestAuthorization { (status) in
NSOperationQueue.mainQueue().addOperationWithBlock({
if let photoComponent = self.iaView.components[1] as? PhotosComponentView {
self.iaView.selectedComponent = photoComponent
photoComponent.getPhotoLibrary()
}
})
}
}
}