Goal & Background
I am trying to create an expandable UITableView using this tutorial. However, I want the table to update its container's height so that the new height matches the content. The problem is this creates a visual glitch on the last header (section) in the table— but only on the first time the animation is performed.
This is what it looks like: link
My thought is that as the table expands the hidden cells, the last row is pushed out of view. So when I update the height of the view, it has to redraw the last cell (notice the color change as it's reloaded). I'm not sure where the strange slide-in animation comes from though.
Question
How would I remove this glitch or better accomplish this task?
Code
Here is my hierarchy:
+-- ParentVC
| +-- ParentView
| | +-- CustomTableVC's View
| | | +-- Custom UITable
(CustomTableVC is a child of ParentVC)
This is how I reload the tapped section and set the new height
// === CustomTableVC === //
func toggleSection(_ header: PTTableHeader, section: Int) {
...
// Reload the section with a drop-down animation
table.reloadSections(NSIndexSet(index: section) as IndexSet, with: .automatic)
// Update the height of the container view
preferredContentSize.height = table.contentSize.height
}
// Height for section headers and rows: 44 (including estimated)
And here is how the parent is updated:
// === ParentVC === //
override func preferredContentSizeDidChange(forChildContentContainer container: UIContentContainer) {
super.preferredContentSizeDidChange(forChildContentContainer: container)
if let child = container as? PTTable {
customTableVC.view.layoutIfNeeded()
customTableViewHeightAnchor.constant = child.preferredContentSize.height
UIView.animate(withDuration: 3, animations: {
view.layoutIfNeeded()
})
}
// Height anchor starts at parentView's height / 3 because
// I'm not sure how to make it match the table's contentSize from the get-go
}
Removing view.layoutIfNeeded() causes the last section to not perform the slide-in animation but still glitch out.
Running on iPhone 11 Pro (simulator).
Reflection
I got it to work. The trick was actually pretty simple once I figured it out. I still think it's a little convoluted/smelly, but it works for my purposes and with the method I used to begin with.
Solution
Basically, I set the preferredContentSize before I reload the sections. This alerts the ParentVC to start animating before anything actually changes. This means that the table now has space to move the bottom section into without having to reload it.
Code
// === CustomTableVC === //
func toggleSection(_ header: PTTableHeader, section: Int) {
...
// Predict the height of the table BEFORE reloading it
predictHeight(section: section, willExpand: isExpanding) // Change isExpanding with whatever Bool is tracking the expand/collapse state of the section
// THEN reload the section with a drop-down animation
table.reloadSections(NSIndexSet(index: section) as IndexSet, with: .automatic)
// Optionally do the above with an animation using UIView.animate() or UIView.transition()
// And FINALLY update the height of the container view
// This one is probably optional, but it will be more exact depending on what methods you use to predict the height in predictHeight()
preferredContentSize.height = table.contentSize.height
}
func predictHeight(section: Int, willExpand: Bool) {
// Get the heights of all the known headers/footers/rows
let tableSectionsHeight = CGFloat(table.numberOfSections) * (table.estimatedSectionHeaderHeight + table.sectionFooterHeight)
let tableCellsHeight = CGFloat(table.visibleCells.count) * table.estimatedRowHeight
// Calculate the height of the section being expanded/collapsed
// With the method I used, I can't just do table.numberOfRows(inSection: Int) since expanding/collapsing is essentially just adding/removing those rows
// Instead I need to store a reference to the number of rows per section and access it via that array, object, etc.
let sectionContentHeight = willExpand ? CGFloat(rowCounts[section]) * table.estimatedRowHeight : 0 // 0 if collapsing
// Set the preferredContentSize so that the ParentVC picks it up
preferredContentSize.height = tableSectionsHeight + tableCellsHeight + sectionContentHeight
}
Related
I have UITableView with sections and rows(https://imgur.com/a/hrYTEVR). I know how enable reordering for rows, but i dont know how implement reordering for sections.
I need add reordering control to sections(https://imgur.com/a/V5kU9Ew) and then when user touch this control, rows under section should flops. After that user can move section to new place.
After read topics on stackoverflow and other sites, i dont find any good idea how implement something like this.
I thought about implement sections through cells, but in this case i can't flop rows under section for further moving to new place.
If you have any idea how implement this – give me advice. Thanks!
There is no native functionality to achieve what you want. If I understand correctly you would want to collapse a whole section of rows and then start dragging the "header" around. If you want to do this on your own I would suggest starting with a pan gesture recognizer which triggers on the header button.
The gesture should be relatively obvious. After it starts on the header you need to track position using locationIn in your table view.
To collapse rows all you need to do is modify your table view cells with appropriate animation like:
tableView.beginUpdates()
tableView.deleteSections([myIndexPath], with: .top) // Maybe experiment with animation type
// Modify whatever you need to correspond this change in the data source
tableView.endUpdates()
Since you will be removing the section you will also be removing the view (header) which has the gesture recognizer. That means it might be better adding the gesture to the table view directly or its superview even. You will need to force it to trigger only when one of those buttons on headers is pressed. You can get some idea here about it. The rest is unaffected by this change.
At this point you will probably need to create an extra view which represents your section stack and follows your finger. This should be pretty easy if you add it as a subview and manipulate it's center with pan gesture recognizer locationIn in it's superview:
movableSectionView.center = panGestureRecognizer.location(in: movableSectionView.superview!)
So up to this point you should be able to grab a section which collapses all cells and be able to drag the "section stack" view around. Now you need to check where in table view your finger is to know where to drop the section. This is a bit painful but can be done with visibleCells and tableView.indexPath(for: ):
func indexPathForGestureRecognizer(_ recognizer: UIGestureRecognizer) -> IndexPath {
let coordinateView: UIView = tableView.superview! // This can actually be pretty much anything as long as it is in hierarchy
let y = recognizer.location(in: coordinateView).y
if let hitCell = tableView.visibleCells.first(where: { cell in
let frameInCoordinateView = cell.convert(cell.bounds, to: coordinateView)
return frameInCoordinateView.minY >= y && frameInCoordinateView.maxY <= y
}) {
// We have the cell at which the finger is. Retrieve the index path
return tableView.indexPath(for: hitCell) ?? IndexPath(row: 0, section: 0) // This should always succeed but just in case
} else {
// We may be out of bounds. That may be either too high which means above the table view otherwise too low
if recognizer.location(in: tableView).y < 0.0 {
return IndexPath(row: 0, section: 0)
} else {
guard tableView.numberOfSections > 0 else {
return IndexPath(row: 0, section: 0) // Nothing in the table view at all
}
let section = tableView.numberOfSections-1
return IndexPath(row: tableView.numberOfRows(inSection: section), section: section)
}
}
}
Once the gesture recognizer ends you can use this method to get the section you are dropping your items into. So just:
tableView.beginUpdates()
// Modify whatever you need to correspond this change in the data source
tableView.insertSections([indexPathForGestureRecognizer(panGestureRecognizer).section], with: .bottom)
tableView.endUpdates()
This should basically be enough for reordering but you might want to show in table view where the dragged section is. Like having a placeholder at the end of the section in which the stack will be dropped into. That should be relatively easy by simply adding and then moving an extra placeholder cell reusing indexPathForGestureRecognizer to get a position for it.
Have fun.
I have a chat application where I am using the inverted table view technique (table view is scaled to (1, -1) with a transform, and all the cells themselves are scale to (1, -1) as well, neutralizing the effect, thus effectively inverting the table view to display the "first" cell at the bottom) to "start" the table view from bottom. Everything works fine, except that I need to implement peek and pop gesture. I've connected a button inside my table view cell to the target view controller, and enabled peek and pop as shown:
The gesture works, though when I 3D touch partially and hold enough to stand out the touched item (but not pop the preview view controller) I am getting inverted view:
How can I make the view pop out with correct transform, while still using the inverted table view?
The reason why it shows the view upside down after a light press is not because it removes the transform from the cell. In fact the transform on the cell stays as it is but the segue pulls the cell view out of the context of the tableView. Therefore the transform on the cell is not counteracted by the transform on the table anymore.
I believe you could fix this by subclassing UIStoryboardSegue but it would probably be much easier to switch your method of starting the table at the bottom. Check out the accepted answer on this question.
I used a slightly modified version which accounts for the navigation bar and status bar:
func updateTableContentInset() {
let numRows = tableView(self.tableView, numberOfRowsInSection: 0)
var contentInsetTop = self.tableView.bounds.size.height - (self.navigationController?.navigationBar.frame.size.height)! - 20
for i in 0..<numRows {
let rowRect = self.tableView.rectForRow(at: IndexPath(item: i, section: 0))
contentInsetTop -= rowRect.size.height
if contentInsetTop <= 0 {
contentInsetTop = 0
}
}
self.tableView.contentInset = UIEdgeInsetsMake(contentInsetTop, 0, 0, 0)
}
Just add this to your UITableViewController and call it on viewDidLoad() and every time a new row is added to the table.
After adding a new row and then calling updateTableContentInset() you will have to scroll to the bottom like this:
tableView.scrollToRow(at: indexPath, at: .bottom, animated: true)
Since there is nothing upside down here you will not have problems with your Peek segue.
Say you have a table view T which shows messages,
T[ message d
T[ message c
T[ message b
T[ most recent message down bottom
Say there are 100 messages, with the bottom 4 visible in the example.
So the table view height is 700 say. You have a typical text entry underneath...
T[ message d
T[ message c
T[ message b
T[ most recent message down bottom
[enter snapped chat message!] [send]
When the keyboard appears, the new height of the visible table view is 400 say.
Of course, that will "cut off the bottom" of the messages - the most recent two will no longer be visible.
T[ message d
T[ message c
[enter snapped chat message!] [send]
[ iOS keyboard ]
[ iOS keyboard ]
(So, messages A and B are now "under" the keyboard.)
Naturally what you do is just scroll the table after the keyboard appears, for example. No problem doing it in an ad-hoc manner.
However, it would be really natural if one could subclass UITableView in such a way that, as the size of the visible area changed, the table view knew to keep the "bottom point" identical.
So, as the bottom of the table moves up and down (due to keyboard appearing - or whatever cause), the table would scroll actually based on the movement of the "base" of the table.
(Apart from anything else this would solve the "match the animation timing" nuisance.)
Could this be achieved elegantly and if so how - indeed this would seem so natural these days, perhaps it's built-in to UITableView as a flag and I just don't know?
once again the question here is
How to modify UITableView so that it moves its own scroll position, as, the view size changes...
(so as to keep the "bottom point the same")
Note that it's trivial to just scroll the table "manually" as it were from the outside.
Section headers stick to the top, so maybe something like this:
1. Make the most recent message a section header view instead of a table cell
2. Mirror the table view vertically:
tableView.transform = CGAffineTransformMakeScale(1, -1);
3. Mirror vertically the section header and table cells
4. Reverse the order of your messages
Is that what you were looking for?
This is a way late answer, but I just solved the problem a bit more elegantly than the very ingenious accepted solution.
You'll need to have the following AutoLayout constraints (I'll omit the horizontal constraints):
UITableView.top = Safe Area.top
UITableView.bottom = MessageContainerView.top
MessageContainerView.bottom = Safe Area.bottom
MessageContainerView is where I keep the message text view and button. Now, in your ViewController add an IBOutlet for the bottom constraint of MessageContainerView:
#IBOutlet weak var messageContainerBottomConstraint: NSLayoutConstraint!
Next step is animating the bottom constraint's constant to be equal to the keyboard height + the safe area at the bottom and scrolling the table to the bottom when the keyboard is shown (add an observer for the notification as well):
#objc func showKeyboard(_ notification: NSNotification) {
guard let userInfo = notification.userInfo,
let targetFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue,
let animationTime = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double
else {
return
}
let safeAreaBottom = view.safeAreaInsets.bottom
messageContainerBottomConstraint.constant = -targetFrame.cgRectValue.height + safeAreaBottom
UIView.animate(withDuration: animationTime) {
self.view.layoutIfNeeded()
self.tableView.scrollToBottom()
}
}
Note that we use UIResponder.keyboardAnimationDurationUserInfoKey for the animation timing, so everything will be smooth.
Lastly, tableView.scrollToBottom() is an Extension for UITableView:
import UIKit
extension UITableView {
func scrollToBottom() {
let numberOfSections = self.numberOfSections
let numberOfRowsInSection = self.numberOfRows(inSection: numberOfSections - 1)
let indexPathForLastRow = IndexPath(row: numberOfRowsInSection - 1, section: numberOfSections - 1)
self.scrollToRow(at: indexPathForLastRow, at: .bottom, animated: false)
}
}
Let me know if you can try it, and if I missed anything.
(I did notice you asked for a way to modify the native UITableView behavior instead of "manually" scrolling, but I think this is a valid way of solving the problem)
As far as I know, you have a few choices for doing this:
If you have the indexPath of the row you want to be visible:
tableView.ScrollToRow(indexPath, UITableViewScrollPosition.Top, true);
This will scroll to the cell given by the indexPath. In this case you are selecting the option to show it on the .Top of the screen.
If you don't have the indexPath, you can use:
tableView.ScrollRectToVisible(new RectangleF(0, Ydisplacement, tableView.Width, tableView.Height), true);
I have this table view which in some cases contains few rows, even one.
What’s wrong with it is that even if I have one row, it allows me to scroll down and the row gets hidden at top.
It practically almost disappears from screen, as if there would be somethingto show below it.
I can’t disable scrolling because I have pull down to refresh.
Any ideas if there is a setting I am missing? Or how I could not allow scroll down if I do not have enough rows to cover the whole screen?
Actually, your case is kind of tricky, because:
The first I thought that the solution will be myTableView.alwaysBounceVertical = false
That's will do the job for you, but the problem in your case that you have a UIRefreshControl() and setting alwaysBounceVertical to false will disable scrolling to top for displaying the refreshController.
So, it should be done manually, as follows:
1- Implement the scrollViewDidScroll method from UIScrollViewDelegate.
2- check the scrolling direction in it.
3- if the scrolling direction goes down, check if content size of the tableView is more than its height, i.e check if tableView contains cell more than its height.
4- if the output of step 3 is false, disable scrolling, else, enable scrolling.
5- add dispatch_after to re-enable tableView scrolling.
It goes like this (Note: Swift 2 code.):
private var lastContentOffset: CGFloat = 0
// 1
func scrollViewDidScroll(scrollView: UIScrollView) {
// 2
if (self.lastContentOffset > scrollView.contentOffset.y) {
print("scrolling up")
}
else if (self.lastContentOffset < scrollView.contentOffset.y) {
print("scrolling down")
// 3 and 4
myTableView.scrollEnabled = myTableView.contentSize.height > myTableView.frame.size.height ? true : false
// 5
// delaying is half a second
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, Int64(NSEC_PER_MSEC) * 500), dispatch_get_main_queue(), { () -> Void in
self.myTableView.scrollEnabled = true
})
}
}
Hope this is a good solution for your case.
I'd really appreciate if someone can light up some ideas here. I've been trying to fix this for weeks now (not kidding).
I have a "to do list" using a collectionView where I hide the rows that were completed and move them to the end of the list. I then unhide the items if needed with a button. The collectionView looks exactly as a tableView with one item(cell) per row.
When the items are hidden the collectionView has a lot of empty scrolling space at the bottom instead of automatically deleting the space used by the hidden rows since they technically are still there.
I'm trying to cut that empty space so the collectionView height would be equal to the amount of cells/rows left visible.
override func viewDidAppear(animated: Bool) {
if isCellHidden { //checking if cells are hidden
var heightOfSection0 = 95 + (42 * self.collectionView.numberOfItemsInSection(0))
println(self.collectionView.contentSize.heigh) //returns 2350
self.collectionView.contentSize.height = CGFloat(heightOfSection0)
println(heightOfSection0) //returns 1019
println(self.collectionView.contentSize.heigh) //returns 1019.0 which is correct but as soon as I scroll down it resets to it's original size (2350) and let's me scroll through that empty space...
}}
If I try to read the collectionView height immediately after setting this, it displays the correct value but as soon as I try to scroll down it resets back to it's original height. I also tried disabling the auto layout and it doesn't make a difference
You should not manage contentSize directly - return appropriate number of items to be displayed from collectionView(_:numberOfItemsInSection:) instead (i.e. do not count your "hidden" cells).
You can use sizeForItemAtIndexPath: to change the size of collection view cell.
func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize {
var numberOfCellInRow : Int = 3
var padding : Int = 5
var collectionCellWidth : CGFloat = (self.view.frame.size.width/CGFloat(numberOfCellInRow)) - CGFloat(padding)
return CGSize(width: collectionCellWidth , height: collectionCellWidth)
}
Changing UI stuff in viewDidAppear() in theory is a good place to do so. However, at this point, you can't be sure that auto layout has operated on the subview that you're manipulating.
You can check when it has and when it's safe to manipulate the frame, by subclassing it and overriding layoutSubviews()
I had a similar issue here: https://stackoverflow.com/a/30446125/4396258