Animate height change UITableView but ignore UILabel? - ios

Currently, I expand the height of my UITableViewCell to add a 45px bottom bar to the view. When I call tableView.beginUpdates() and tableView.endUpdates(), the height animates correctly, but it causes my text to appear to be re-drawn from the center of the cell to the top.
This is pretty jarring, because the text is actually in the same place after this bar is shown and it seems to be animating because a new bottom anchor for the UILabel is set. Is there any way to just animate the bottom bar and keep the UILabel in the same location? Attached is what is currently ocurring:
https://i.imgur.com/eMaD4nP.gifv
I have tried running it in an animation-less completion block like so:
UIView.performWithoutAnimation {
tableView.beginUpdates()
tableView.endUpdates()
}
which keeps the text in the same place, but does not animate the bottom bar at all (as was expected with this method). Ideally, I could achieve the same with the UILabel and not with the rest of the UITableViewCell.
Edit: Some of my AL code that is running before I do beginUpdates(). I am using a library called Anchorage which makes the AL syntax a lot easier to understand, but this is effectively making visual layout constraint language with a custom syntax
NSLayoutConstraint.deactivate(menuHeight)
menuHeight = batch {
menu.heightAnchor == CGFloat(45)
menu.horizontalAnchors == contentView.horizontalAnchors
menu.bottomAnchor == contentView.bottomAnchor
title.bottomAnchor == menu.topAnchor - CGFloat(8)
menu.topAnchor == title.bottomAnchor + CGFloat(8)
}

Related

UIScrollView - Disabling isScrollEnabled drags the whole view upwards

I have a UIScrollView with a couple of subviews, one of them is a UIStackView that can be hidden/displayed when a button is pressed.
When the UIStackView is hidden I want to disable scrolling on the UIScrollView since the content will never have enough height that justifies scrolling being enabled. To achieve that, I'm using:
isScrollEnabled.toggle()
Problem is, when doing this, the whole view gets dragged up, as if I was scrolling down, causing certain elements to be hidden behind the navigationBar and the statusBar.
I tried to fix that using the following piece of code:
if let navigationBarHeight = vcDelegate?.navigationController?.navigationBar.frame.height {
if let statusBarHeight = window?.windowScene?.statusBarManager?.statusBarFrame.height {
let totalHeight = navigationBarHeight + statusBarHeight
setContentOffset(.init(x: 0, y: -totalHeight), animated: true)
}
}
And that does actually work correctly when the contentOffset.y is >= a certain value that I can't precisely specify (because, for some reason, printing the value of contentOffset.y only shows a value that is != 0 when I scroll all the way to the bottom of the view.)
When the contentOffset.y is < than that mystery value, disabling the scroll will cause the following behavior:
The view will be dragged up
The view will be dragged down (because I call the setContentOffset function)
My bottom line question is: why does the view get dragged up when I disable scrolling? Could it be a constraint issue, some "background" offset change that I should be aware of or something else?

Sticky Menu in iOS Table/List

I'm trying to create an interface similar to the Home scene in the meetup app. You can see it in action below. I want to recreate the [All, Going, ...] menu behavior. I want it to start in the middle of the list and scroll up until it reaches the top of the list and stick there. Very similar to how section headers work in a UITableView.
Creating the menu is not the issue. My problem is creating the sticky behavior and have it work well with the rest of the list.
I've tried using a UITableView but I couldn't get the menu cell to stick. I can't put the menu in a section header because I want to use section headers for the data below the menu and UITableView's behavior is to push a section header up when the next section reaches the top of the list. I can't put the menu in the UITableView.tableHeader because the menu starts below some other data in the list.
UITableView
- UITableViewCell -> Label
- UITableViewCell -> UICollectionView of UIImageViews
- UITableViewCell -> Label
- UITableViewCell -> MyMenu (Sticky)
- UITableViewHeaderFooterView - Section 1
- UITableViewCell -> Data
- UITableViewCell -> Data
- UITableViewHeaderFooterView - Section 1
- UITableViewCell -> Data
- UITableViewCell -> Data
I've tried using a UIScrollView containing the menu and a UITableView below it but using a UITableView (which is a UIScrollView) inside a UIScrollView is painful. I couldn't get the scrolling behavior to feel natural.
UIScrollView
- UIView -> (Container)
- Label
- UICollectionView of UIImageViews
- Label
- MyMenu (Sticky)
- UITableView - Data
I'm about to try and write a UICollectionViewLayout to do what I want but I feel like I will have to recreate functionality that I get for free with UITableView.
Any idea how to approach this? Perhaps there is a reliable method to make a UITableViewCell stick and for subsequent section headers to stick under it?
One way to implement something like this is with a view hierarchy like this:
UIView
- UITableView
- UIView -> (Container)
- Label
- UICollectionView of UIImageViews
- Label
- MyMenu (Sticky)
Your container with your menu is a sibling of the table view, but it overlaps it.
In the scroll view delegate method scrollViewDidScroll(_:) you can reposition your menu container view so the menu is positioned above the table content. Then you need to tell the table view to reserve some space between the top and the first table cell. For this you can configure the contentInset of the table view.
I would use a table view.
Add an empty cell that will be where your control will be placed while it's visible, and to avoid your control covering any content.
Add your control as a subview of your table view.
Then override scrollViewDidScroll (UITableView is a subclass of UIScrollView so they share delegate methods).
In scrollViewDidScroll, which gets called at least every frame while the scroll view is scrolling, update the position of the content, like this:
let controlFrame = tableView.rectForRow(at: indexPathOfYourBlankCell)
controlFrame.origin.y = max(0, tableView.contentOffset.y - controlFrame.y)
control.frame = controlFrame
tableView.bringSubviewToFront(control)
Keep in mind that you will have to tweak the second line if your table view has a top inset, for example, if it's under a transparent navigation bar, or you're using an iPhone with a notch.
I suggest implementing it first o an notch-less iPhone simulator, with no navigation bar, and once it works you can tweak the way the y property is calculated by adding the inset.
I think something like this would work, but I'm not sure.
controlFrame.origin.y = max(0, tableView.contentOffset.y + tableView.contentInsets.top - controlFrame.y)
I implemented #EmilioPelaez's suggestion of using a separate menu view, positioning it over an empty cell and moving it as the table scrolls. To make it work I had to do the following things:
Find the frame of the empty cell so I can position the menu over it
As the empty view moves outside the visible area of the table view move the menu so it stays inside the visible area of the table view. It should look like it is docked to the top of the table view.
When the empty cell reaches the top adjust the tableView.contentInsets.top so the section headers below look like they stick to the bottom of the menu
When the table scrolls in the other direction reset the tableView.contentInsets.top
Support dynamic type and rotation changes
I ended up doing everything in viewDidLayoutSubviews because I need to handle rotation and dynamic text changes and scrollViewDidScroll isn't called on every rotation and dynamic text change. viewDidLayoutSubviews is called after almost every scrollViewDidScroll.
let menuCellPath = IndexPath(row: 1, section: 1)
var tableViewInsetCached = false
var cachedTableViewInsetTop: CGFloat = 0.0
override func viewDidLayoutSubviews() {
// Cache the starting tableView.contentInset.top because I need to change it later
// when the menu is docked to the top of the table
if !tableViewInsetCached {
cachedTableViewInsetTop = tableView.contentInset.top
tableViewInsetCached = true
}
// Get the frame of the empty cell. Use rectForRow instead of cellForIndexPath so it
// works even if the cell has been reused.
let menuCellFrame = tableView.rectForRow(at: menuCellPath)
// Calculate how far the menu must move to continue to be within the
// visible area of the scroll view. If the delta is a negative number
// the cell is within the visible area so clamp it at 0, i.e., don't move it.
// Use `tableView.safeAreaInsets.top` to take into account the notch, translucent
// UINavigationBar, and status bar.
let menuFrameDeltaY = max(0, tableView.safeAreaInsets.top + tableView.contentOffset.y - menuCellFrame.origin.y)
// Add the delta to the menu's frame
var newMenuFrame = menuCellFrame
newMenuFrame.origin.y = menuCellFrame.origin.y + menuFrameDeltaY
menuView.frame = newMenuFrame
if menuFrameDeltaY > 0 {
print("cell outside visible area -> change contentInset")
// Change the contentInset so subsequent section headers
// stick to the bottom of the menu
tableView.contentInset.top = menuCellFrame.size.height
} else {
print("cell inside visible area -> reset contentInset")
// The empty cell is inside the visible area so we should
// reset the contentInset
tableView.contentInset.top = cachedTableViewInsetTop
}
}
It's important to remember that we are dealing with a UIScrollView under the hood. The frames of its subviews don't change as the table is scrolled. Only the contentOffset changes which means that max(0, tableView.safeAreaInsets.top + tableView.contentOffset.y - menuCellFrame.origin.y) calculates the amount the menu must move to continue to be within the visible area of the table view. If the delta is less than zero the empty cell is within the visible area of the table view and I don't have to move the menu, just give it the same frame as the empty cell which is why I use max(0, x) to clamp it at zero. If the delta is greater than zero the empty cell is no longer within the visible area of the table view and the menu must be moved to continue to be within the visible area.

How to animate UIStackView hiding inside UITableViewCell properly?

I want to animate the subviews of the TableViewCell which is StackView. When I hide the StackView, the TableViewCell height not updating. After googling, I found that I should call tableView.beginUpdates and tableView.endUpdates to notify tableView that there is a change in the cell. The problem is the hide animation and the change of tableview not sync.
Here is the view hierarchy for tableview cell
Content view - Container View (for card shadow) - Container Stack View - [Stack View for label and switch] & [StudentStackView for container of StudentView]
How can I sync the cell height and hide animation the correct way?
Here is the github repo: GitHub
Gif of the App:
You are right in using beginUpdates()/endUpdates(). Make sure you're not placing the someArrangedSubview.isHidden = true/false in an animate block since the table view and stack view will handle the animations accordingly. When the table view begins update operations, the stack view will resize any arranged subviews that you aren't removing to fill the entire space of the cell (even if you have height constraints on the arranged subview). In my case, the cell content jumped every time I wanted to collapse a cell via removing an arranged subview--so I added a dummy view between the view I wished to remain static* and the collapsible view. The static view won't resize, and the dummy view will expand/collapse as needed. Hope this helps.
*static in the sense that I didn't want the view to move when animating.
`public func setup(classRoom: ClassRoom, toggleInProcess: #escaping () -> (), toggled: #escaping () -> ()) {
containerStackView.addArrangedSubview(studentStackView)
self.nameLabel.text = classRoom.name
self.activeSwitch.isOn = classRoom.isActive
self.studentStackView.isHidden = !self.activeSwitch.isOn // Let him know his hide/unhide.
for student in classRoom.students {
let studentView = StudentView()
studentView.nameLabel.text = student.name
studentStackView.addArrangedSubview(studentView)
}
activeSwitch.addTarget(self, action: #selector(toggleShowStudents(show:)), for: .valueChanged)
self.toggleInProcess = toggleInProcess
self.toggled = toggled
setupShadow()
}`
` #objc func toggleShowStudents(show: Bool) {
UIView.animate(withDuration: 0.3, animations: {
self.studentStackView.isHidden = !self.activeSwitch.isOn
self.toggleInProcess()
self.containerView.layoutIfNeeded()
}) { _ in
self.toggled()
}
}`
your studentStackView also know his hide/unhide status while assigning values in function setup.
I left this as a comment but for anyone else experiencing this behavior, the root cause is the UILabel is expanding to fill the visible area before collapsing.
This can be fixed by doing the following 2 things:
Right below the UILabel, insert a Blank UIView
Adjust the Content Hugging Priority of the UILabel to "Required"
With these two adjustments, instead of the UILabel expanding to fill the visible area, the UIView expands instead. Visually, this appears as if the the cell just collapses.
tableView.beginUpdates and tableView.endUpdates are functions that should be called when you are about to modify rowcount or selected state of the rows.
You should try reloadData or reloadrowsatindexpaths, that should take care of the cell height adjustment.
You would better do it using performSelector API so as not to cause recursion in cellForRowAt call stack.

UIScrollView weird behavior with UITextField/UIPickerView

So I'm pretty puzzled right now because my UIScrollView is acting pretty weirdly.
Basically, I have a UIScrollView that is about twice the height of an iPhone 6 that I am using to display some graphs using iOS Charts and so I've set it to be able to scroll and bounce vertically, but neither scroll nor bounce horizontally. All of the graphs, and some additional UITextFields and UILabels are embedded on a separate "body view" that is aligned with the frame of the UIScrollView as seems to be common practice. Thus, the hierarchy of my views looks like this:
This worked well until I noticed today that when I press a specific UITextField on this UIScrollView, which triggers a UIPickerView, all of the sudden my scroll view starts to allow horizontal bouncing. This behavior does not occur for the two other UITextField's on the body view when they are tapped.
I've checked all of the code that is being triggered by tapping on the affected text field, and nothing is directly editing the frames or bounds of any UI objects. In fact, the only function called when the text field is tapped on is the textFieldDidBeginEditing. I've attached the code for this function below, but I am fairly certain it is not the problem.
My next suspicion was that the UIPickerView popping up has been messing with the dimensions of my scroll view and/or it's embedded view. I'm not quite sure if this is possible/probable, but this whole thing has left me pretty stumped.
Here's my code:
func textFieldDidBeginEditing(_ textField: UITextField) {
if textField == overallTimeframeTextField {
...
// Not the problematic text field
} else if textField == subjectTimeframeTextField {
...
// Also not the problematic text field
} else { // Affected text field
// Set the text of the text field
if textField.text == "" {
// This is executed in this scenario
textField.text = subjectPickerData[0]
} else {
...
}
}
}
Here is a short GIF outlining my issue. You can see me scrolling down the page, where I am not able to bounce horizontally, and then once I tag on the text field, all of the sudden the scroll view allows the bounces.
GIF
I'm pretty lost with this issue, so any help would be appreciated.
Edit
To clarify, I explicitly declare my scrollView's content size to be equal the desired height and the width of the screen that the user is on. I then set the bodyView's width to equal the same value. This is done in viewDidAppear with the following code:
// Fit the content to the screen
scrollView.contentSize = CGSize(width: UIScreen.main.bounds.width, height: 1200)
bodyView.frame.size.width = UIScreen.main.bounds.width
I also have constraints which force the scrollview and body view to both have the same width as the UIViewController's default child view (the parent of the scroll view in my hierarchy).
One interesting thing that I've noticed is that when I print the width of my scroll view and my body view when the views load, I receive the following output for iPhone 6:
385.0
385.0
This is correct as that is the width of an iPhone 6. However, when I tap on the text field, and then print the same values, I get this output:
385.0
384.0
So for some reason, my body view is one point smaller than my scroll view. I've tried setting the body view's width to be equal to the scroll view when I tap on the text field, like I do in the viewDidAppear function, but this had no effect.
In terms of the UIPickerView, I initialize a pickerview with my class instance variables like so:
var subjectPickerView = UIPickerView()
I then assign this picker view to be the input view for the text field in viewDidLoad:
textField.inputView = subjectPickerView
So I'm not sure if this makes the picker view a subview of the scroll view, but it's just replacing the keyboard in this scenario.
Thanks to #AchmadJP's comment, I tried explicitly creating an equal widths constraint between my scroll view and my body view. This seems to have solved the issue.
The reason I had not done this previously was that the body view's leading space, trailing space, top space and bottom space were constrained to be the same as those of the scroll view. Theoretically, this should have meant that the widths were equal at all times, but apparently, that is not the case.
For anyone else with the same problem, you can see this answer for the solution.

Autoresizing UIView with UILabel inside, smoothly

I have a UIView, that contains a UILabel (and nothing else) inside it. I wish to expand and contract the view, making it look as though the label is expanding and contracting (via a button tap). I'm using a NSLayoutConstraint on the height of the view:
func labelExpansion() {
if (isExpanded) {
myViewConstraint.constant = shortLabelHeight
} else {
myViewConstraint.constant = longLabelHeight
}
UIView.animate(withDuration: 2.2, animations: {
self.view.layoutIfNeeded()
})
}
The problem is, the text in the label looks like it 'jumps'. As the label is resizing, the position of the text changes, until the animation is finished, when the label correctly redraws to the top of the view.
I have also tried removing the surrounding view, and adjusting the height constraint of the label alone; that was similarly jumpy.
How can I stop this jump during the animation, and fix the top of the label to the top of the view?
After trying a few things, I found an easy solution: set the Content Mode of the UILabel to 'Top'.
myLabel.contentMode = .top

Resources