Center UIView in container without jumping - swift

I have this structure:
container (UIView)
Header (UIView)
TableView
CollectionView
CollectionView
CollectionView
When a favorite button is pressed I want a view to be displayed on top of everything and to be centered. Whenever it is added when the tableView has not been scrolled, it works fine. However, when added after it has been scrolled the whole layout kind of jumps into place. The more scroll, the more jumping. Visual representation of the layout:
https://gyazo.com/8ddc75ce1ac96ff00aa9a9d8b0d4b725
The view is loaded from a xib and centered in its container. The view height and width anchors are already set (and translatesAutoresizingMaskIntoConstraints to false). Here is the configure method of the view to be centered.
public func configure(viewToCenterItselfInside: UIView) {
self.centerXAnchor.constraint(equalTo: viewToCenterItselfInside.centerXAnchor).isActive = true
self.centerYAnchor.constraint(equalTo: viewToCenterItselfInside.centerYAnchor).isActive = true
UIView.animate(withDuration: 0.5) {
self.alpha = 1
}
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1500)) {
UIView.animate(withDuration: 0.5, animations: {
self.alpha = 0
}) { (_) in
self.removeFromSuperview()
}
}
}
Here is the code when the view is loaded
if let addedFavorite = Bundle.main.loadNibNamed("ConfirmationView", owner: nil, options: nil)?.first as? ConfirmationView {
view.addSubview(addedFavorite)
addedFavorite.configure(viewToCenterItselfInside: view)
}
In addition to trying to solve it with constraints I've also tried centering it using self.center = viewToCenterItselfInside and messing around with the UIScreen.main without any results. Any ideas?

Related

Prevent cell content from "jumping" when applying constraint

I have a subclassed UICollectionViewCell and I want it to expand when tapped.
To achieve this, I put the title into a view ("titleStack") and the body into a separate view ("bodyStack"), and then put both of them into a container UIStackView ("mainStack"). I then constrain the contentView of the cell to the leading, trailing, and top edges of mainStack.
When the cell is selected, a constraint is applied that sets the bottom of the contentView's constraint to be the bottom of bodyStack. When it's unselected, I remove that constraint and instead apply one that sets the contentView's bottom constraint equal to titleStack's bottom constraint.
For the most part this works well, but when deselecting, there's this little jump, as you can see in this video:
What I would like is for titleStack to stay pinned to the top while the cell animates the shrinking portion, but it appears to jump to the bottom, giving it a sort of glitchy look. I'm wondering how I can change this.
I've pasted the relevant code below:
private func setUp() {
backgroundColor = .systemGray6
clipsToBounds = true
layer.cornerRadius = cornerRadius
setUpMainStack()
setUpConstraints()
updateAppearance()
}
private func setUpMainStack() {
contentView.constrain(mainStack, using: .edges, padding: 5, except: [.bottom])
mainStack.add([titleStack, bodyStack])
bodyStack.add([countryLabel, foundedLabel, codeLabel, nationalLabel])
}
private func setUpConstraints() {
titleStack.topAnchor.constraint(equalTo: contentView.topAnchor).isActive = true
closedConstraint =
titleStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
closedConstraint?.priority = .defaultLow // use low priority so stack stays pinned to top of cell
openConstraint =
bodyStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
openConstraint?.priority = .defaultLow
}
/// Updates the views to reflect changes in selection
private func updateAppearance() {
UIView.animate(withDuration: 0.3) {
self.closedConstraint?.isActive = !self.isSelected
self.openConstraint?.isActive = self.isSelected
}
}
Thanks so much!
I was able to solve this by simply showing and hiding my "bodyStack" as well as using "layoutIfNeeded." I removed closedConstraint and openConstraint and just gave it a normal bottom constraint.
The relevant code:
func updateAppearance() {
UIView.animate(withDuration: 0.3) {
self.bodyStack.isHidden = !self.isSelected
self.layoutIfNeeded()
}
}

Change size of view and move the surrounding views along

I have a view that I want to change the size of with animation. Below that view are other views that I want to move accordingly.
I have created a new project just so simplify this for me to get this function to work properly. In this project I only have viewOne, viewTwo and a button to control this. There is also a boolean called "blue".
#IBAction func bttn() {
if blue {
blue = false
UIView.animate(withDuration: 1) {
self.viewOne.frame.size.height = 64
}
}else {
blue = true
self.viewOne.frame.size.height = 100
}
}
When I tap the button I expect viewOne to increase in size and viewTwo to be moved down since there is a constraint between the top and bottom of the views.
But the actual result is that viewOne increase but viewTwo does not move.
Only autolayout can do this as changing frames doesn't apply the constraints , You need to create a height outlet for view1 then
self.view1Height.constant = 64
UIView.animate(withDuration: 1) {
self.view.layoutIfNeeded()
}

UIStackView Animation Issue

I have a subStackView inside a stackView and when I hide/show the contents of ONE subStackView, the animation goes all the way up over the other stack views: https://www.youtube.com/watch?v=vKXwX7OpkxU
This is how I create the subStackView. I tried with and without clipToBounds and with an without translatedAutoresizingMaskIntoConstraints. Also tried layoutIfNeeded in the animation part.
let subStackView = UIStackView(arrangedSubviews: [self.innerView[0], self.innerView[1])
subStackView.translatesAutoresizingMaskIntoConstraints = false
subStackView.axis = .vertical
subStackView.distribution = .fillEqually
subStackView.alignment = .fill
subStackView.spacing = 0
subStackView.clipsToBounds = true
This subStackView is then loaded into a mainStackView which results in the issue.
One way to fix your problem is to control more directly how the purple view is shown and hidden. What you're doing now (I assume) is setting isHidden property to true and then letting the stack view do whatever it wants. Instead, let's put the purple view inside a container view, and animate the container view's height down to zero. Then it can look like this:
The reason to use a container view instead of just animating the purple view's height directly is that you might (in general) have other constraints controlling the purple view's height, so also constraining its height to zero would fill up your console with unsatisfiable constraint errors.
So here's what I did for the demo. I made a “Hello, world!” label with a purple background. I constrained its height to 80. I put the label inside a container view (just a plain UIView). I constrained the top, leading, and trailing edges of the label to the container view, as normal. I also constrained the bottom edge of the label to the container view, but at priority 999* (which is less than the default, “required” priority of 1000). This means that the container view will try very hard to be the same size as the label, but if the container view is forced to change height, it will do so without affecting the label's height.
The container also has clipsToBounds set, so if the container becomes shorter than the label, the bottom part of the label is hidden.
To toggle the visibility of the label, I activate or deactivate a required-priority height constraint on the container view that sets its height to zero. Then I ask the window to lay out its children, inside an animation block.
In my demo, I also have the stack view's spacing set to 12. If I just leave the container view “visible” (not isHidden) with a height of zero, the stack view will put 12 points of space after the button, which can look incorrect. On iOS 11 and later, I fix this by setting a custom spacing of 0 after the button when I “hide” the container, and restore the default spacing when I “show” it.
On iOS version before iOS 11, I just go ahead and really hide the container (setting its isHidden to true) after the hiding animation completes. And I show the container (setting its isHidden to false) before running the showing animation. This results in a little bump as the spacing instantly disappears or reappears, but it's not too bad.
Handling the stack view spacing makes the code substantially bigger, so if you're not using spacing in your stack view, you can use simpler code.
Anyway, here's my code:
class TaskletViewController: UIViewController {
#IBAction func buttonWasTapped() {
if detailContainerHideConstraint == nil {
detailContainerHideConstraint = detailContainer.heightAnchor.constraint(equalToConstant: 0)
}
let wantHidden = !(detailContainerHideConstraint?.isActive ?? false)
if wantHidden {
UIView.animate(withDuration: 0.25, animations: {
if #available(iOS 11.0, *) {
self.stackView.setCustomSpacing(0, after: self.button)
}
self.detailContainerHideConstraint?.isActive = true
self.view.window?.layoutIfNeeded()
}, completion: { _ in
if #available(iOS 11.0, *) { } else {
self.detailContainer.isHidden = true
}
})
} else {
if #available(iOS 11.0, *) { } else {
detailContainer.isHidden = false
}
UIView.animate(withDuration: 0.25, animations: {
if #available(iOS 11.0, *) {
self.stackView.setCustomSpacing(self.stackView.spacing, after: self.button)
}
self.detailContainerHideConstraint?.isActive = false
self.view.window?.layoutIfNeeded()
})
}
}
override func loadView() {
stackView.axis = .vertical
stackView.spacing = 12
stackView.translatesAutoresizingMaskIntoConstraints = false
button.translatesAutoresizingMaskIntoConstraints = false
button.backgroundColor = UIColor.green.withAlphaComponent(0.2)
button.setTitle("Tap to toggle", for: .normal)
button.addTarget(self, action: #selector(buttonWasTapped), for: .touchUpInside)
button.setContentHuggingPriority(.required, for: .vertical)
button.setContentCompressionResistancePriority(.required, for: .vertical)
stackView.addArrangedSubview(button)
detailLabel.translatesAutoresizingMaskIntoConstraints = false
detailLabel.text = "Hello, world!"
detailLabel.textAlignment = .center
detailLabel.backgroundColor = UIColor.purple.withAlphaComponent(0.2)
detailLabel.heightAnchor.constraint(equalToConstant: 80).isActive = true
detailContainer.translatesAutoresizingMaskIntoConstraints = false
detailContainer.clipsToBounds = true
detailContainer.addSubview(detailLabel)
let bottomConstraint = detailLabel.bottomAnchor.constraint(equalTo: detailContainer.bottomAnchor)
bottomConstraint.priority = .init(999)
NSLayoutConstraint.activate([
detailLabel.topAnchor.constraint(equalTo: detailContainer.topAnchor),
detailLabel.leadingAnchor.constraint(equalTo: detailContainer.leadingAnchor),
detailLabel.trailingAnchor.constraint(equalTo: detailContainer.trailingAnchor),
bottomConstraint
])
stackView.addArrangedSubview(detailContainer)
self.view = stackView
}
private let stackView = UIStackView()
private let button = UIButton(type: .roundedRect)
private let detailLabel = UILabel()
private let detailContainer = UIView()
private var detailContainerHideConstraint: NSLayoutConstraint?
}

table views only scrolls to textfields bottom edge

The bottommost cell of my table view is a cell with a textField. When the user taps it, I want to scroll it so that the cell is right above the keyboard.
When I call the scrollRectToVisible(...) with animated false everything works as expected, but when animated is set to true the table scrolls the cell only so far, that the bottom of the textField is right above the keyboard (See left picture). Yet the bottonInsets should be correct, since I can scroll the cell the last bit manually and the cell sits right how it should (See right picture).
I think the table view scrolling the textField's bottom edge above the keyboard is the default behavior of a table view, but I'm afraid I don't know why it seems to override my own scrolling when I want it animated.
Left picture:
The textFields bottom edge right above the keyboard (I kept the border style so you can see it better).
Right picture:
How I want it. Cell's bottom edge right above the keyboard.
func repositionTextfieldCell(in tableView: UITableView) {
guard let textFieldCell = tableView.bottommostCell() else { return }
guard let keyboardRect = activeKeyboardRect else { return }
// - Adjust insets
var bottomInset = keyboardRect.size.height
tableView.contentInset.bottom = bottomInset
tableView.scrollIndicatorInsets.bottom = bottomInset
// - Make cell visible
let x = textFieldCell.frame.minX
let y = textFieldCell.frame.maxY
tableView.scrollRectToVisible(CGRect(origin: CGPoint(x: x, y: y),
size: CGSize(width: 1, height: 1)), animated: true)
}
add this in viewDidLoad() and create a NSlayout constraint for tableview bottom.
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardWillShow),
name: NSNotification.Name.UIKeyboardWillShow,
object: nil
)
create the function
#objc func keyboardWillShow(_ notification: Notification) {
if let keyboardFrame: NSValue = notification.userInfo?[UIKeyboardFrameEndUserInfoKey] as? NSValue {
let keyboardRectangle = keyboardFrame.cgRectValue
let keyboardHeight = keyboardRectangle.height
tableBottomConstraint.constant = self.view.frame.height - keyboardHeight
}
}
repeat the process to reset the tableBottomConstraint.constant = 0 in keyboardWillHide() method.
I could fix the problem.
The behavior seems to be depended on were scrollRectToVisible(...) is called. The behavior I described in the question occurs when scrollRectToVisible(...) is called in keyboardDidShow(...).
However when you call scrollRectToVisible(...) in keyboardWillShow(...) and set animated to false the cell / rect is pushed up by the keyboard sliding in. Which I think looks great.

Programmatic hide/unhide edges of UIScrollView in Swift while using autolayout

I'm trying to convert some ObjC for a horizontally scrolling UITableViewCell to Swift. The original ObjC version used explicit sizing of the cell contents but I am trying to use autolayout for consistency across my app.
All was going well up until the point where I wanted to create buttons on the left and right of the screen that can be hidden/unhidden by swiping in the same way as the delete button in the Mail app (but I want a bit more functionality hence a homebrew solution). The original code achieved this by creating scrollview content insets at the left and right that were the same size as the buttons but I have a few conceptual and programmatic issues with this.
Firstly, I understood contentInset as a 'buffer' zone around a scroll view that people often use to prevent the content area being hidden by status or navigation bars (i.e. a way of maximising screen real-estate). As such, the content area of the scrollview should remain constant regardless of contentInsets as they are essentially a margin. However, the original coder creates a negative insets and these seem to magically increase content area but the views within the insets are hidden off-screen. What's going on? Why do we do this rather than just changing the offset to compensate for the width of the off-screen buttons?
Secondly, buttons are revealed by swiping far or fast enough so that the eventual resting point of the scrollview reveals the hidden button. The code to lock the scrollview in position and stop the button from being re-hidden is within the scrollViewWillEndDragging function and changes the targetContentOffset to achieve this. This works when using explicit view and button sizes but fails to work when using autolayout. However, if you call scrollview.setContentOffset it works. Why the difference? Surely it's the same thing but I'm guessing there must be a different sequence of method calls.
Code is below (I've edited out some of the less important material). This is proof-of-concept so not very elegant!
Creating scrollView:
let scrollView = UIScrollView()
scrollView.backgroundColor = UIColor.blueColor()
scrollView.delegate = self;
scrollView.scrollsToTop = false
scrollView.showsHorizontalScrollIndicator = false
scrollView.showsVerticalScrollIndicator = false
self.contentView.addSubview(scrollView)
// pin scrollView to cell contentView
scrollView.setTranslatesAutoresizingMaskIntoConstraints(false)
self.contentView.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:|[sv]|", options: nil, metrics: nil, views: ["sv" : scrollView]))
self.contentView.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:|[sv]|", options: nil, metrics: nil, views: ["sv" : scrollView]))
let scrollViewContent = UIView()
scrollView.addSubview(scrollViewContent)
// add content sequentially (L>>R) to the content subview of scrollView
var lastObjectAdded: UIView? = nil // allows constraints to be created between adjacent contentView subviews
// add button on the left
let leftButton = UIButton.buttonWithType(UIButtonType.Custom) as! UIButton
leftButton.backgroundColor = UIColor.purpleColor()
leftButton.setTitle("Click Me!", forState: UIControlState.Normal)
leftButton.setTitleColor(UIColor.whiteColor(), forState: UIControlState.Normal)
// width needs to be set explicitly so inset for scroll view can be set in this class
// (when using autolayout, frame dimensions are not available until after all subviews are laid out)
leftButton.frame.size.width = CGFloat(100)
scrollViewContent.addSubview(leftButton)
// size using autolayout
leftButton.setTranslatesAutoresizingMaskIntoConstraints(false)
// give button a fixed width
// TODO: Enable this to be defined by function call
scrollViewContent.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:[but(==100)]", options: nil, metrics: nil, views: ["but" : leftButton]))
// pin height to height of cell's contentView
self.contentView.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:[but(==cv)]", options: nil, metrics: nil, views: ["but" : leftButton, "cv" : self.contentView]))
// pin to top left of the scollView subview
scrollViewContent.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:|[but]", options: nil, metrics: nil, views: ["but" : leftButton]))
scrollViewContent.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:|[but]", options: nil, metrics: nil, views: ["but" : leftButton]))
// store a reference to the button to be used for positioning other subviews
lastObjectAdded = leftButton
testView = leftButton
< SNIP ---
--- SNIP>
// test that I can add a label that matches the width of the tableViewCell's contentView subview'
let specialLab = UILabel()
specialLab.setTranslatesAutoresizingMaskIntoConstraints(false)
specialLab.backgroundColor = UIColor.orangeColor()
specialLab.text = "WIDTH MATCHES TABLEVIEWCELL WIDTH"
scrollViewContent.addSubview(specialLab)
scrollViewContent.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:|-(10)-[lab]", options: nil, metrics: nil, views: ["lab" : specialLab]))
// this is the important constraint
self.contentView.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:[lab(==cv)]", options: nil, metrics: nil, views: ["lab" : specialLab, "cv" : contentView]))
scrollViewContent.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:[last]-(10)-[lab]", options: nil, metrics: nil, views: ["lab" : specialLab, "last" : lastObjectAdded!]))
lastObjectAdded = specialLab
// pin last label to right which dictates content size width
scrollViewContent.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:[lab]-10-|", options: nil, metrics: nil, views: ["lab" : lastObjectAdded!]))
// set scrollView's content view height and frame-to-superview constraints
// (width comes from subview constraints)
// content view is calculated for us
scrollViewContent.setTranslatesAutoresizingMaskIntoConstraints(false)
scrollView.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:|[svc]|", options: nil, metrics: nil, views: ["svc" : scrollViewContent]))
scrollView.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:|[svc]|", options: nil, metrics: nil, views: ["svc" : scrollViewContent]))
// create inset to hide button on left
scrollView.contentInset = UIEdgeInsetsMake(0, -leftButton.bounds.width, 0, 0)
The second bit of code relates to un-hiding the button on the left:
extension SwipeableViewCell: UIScrollViewDelegate {
func scrollViewWillEndDragging(scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
let left = CGFloat(100.0) // this is the inset required to hide the left button(s)
let kSwipeableTableViewCellOpenVelocityThreshold = CGFloat(0.6) // minimum velocity req to reveal buttons
let kSwipeableTableViewCellMaxCloseMilliseconds = CGFloat(300) // max time for the button to be hidden
let x = scrollView.contentOffset.x
// check to see if swipe from left is far enough and fast enough to reveal button
if (left > 0 && (x < 0 || (x < left && velocity.x < -kSwipeableTableViewCellOpenVelocityThreshold))) {
// manually set the offset to reveal the whole button but no more
// targetContentOffset.memory = CGPointZero // this should work but doesn't - offset is not retained
dispatch_async(dispatch_get_main_queue()) {
scrollView.setContentOffset(CGPointZero, animated: true) // this works!
}
} else {
// if not, hide the button
dispatch_async(dispatch_get_main_queue()) {
scrollView.setContentOffset(CGPoint(x: CGFloat(100), y: CGFloat(0)), animated: true)
}
}
}
}