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

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)
}
}
}
}

Related

Center UIView in container without jumping

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?

How to programmatically set the constraints of the subViews of a UIPageViewController?

I have contained the subViews of a UIPageViewController within a UIView so that my screen has a partial scrollView container. However, the subViewControllers extend beyond both, the UIView that is supposed to contain the (horizontal/swiping page style) scrollView and the screen of the device.
I have already tried to use autolayout constraints but the subViews still go beyond the device screen.
Here is the UIView that contains the subViews of the UIPVC:
let pagingContainer: UIView = {
let view = UIView()
view.backgroundColor = .white
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
and here is the set up within viewDidLoad():
let pageController = PageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal)
addChild(pageController)
pageController.didMove(toParent: self)
pagingContainer.addSubview(pageController.view)
In case I haven't articulated properly:
What I wish for to happen is that the bottom half of my screen is a horizontal-page-style swiping scrollView that contains x number of subViewControllers (under UIPVC), and the size of subViewControllers are limited to the size of the UIView(pagingContainer).
I think I might understand what you're asking.
It should be pretty simple, set your left/right/top/bottom constraints for the pageController.view to be equal to the pagingContainer
In my example, I'm using SnapKit, so I set the edges equal to superview (which is the paingContainer).
let pageController = PageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal)
self.addChild(pageController)
pageController.didMove(toParent: self)
pagingContainer.addSubview(pageController.view)
// I set up constraints with SnapKit (since I mostly use that pod)
pageController.view.snp.makeConstraints({ (make) in
make.edges.equalToSuperview()
})
// But if I remember correctly, you can also set it like so:
pageController.view.translatesAutoresizingMaskIntoConstraints = false
pageController.view.widthAnchor.constraint(equalTo: self.pagingContainer.widthAnchor).isActive = true
pageController.view.heightAnchor.constraint(equalTo: self.pagingContainer.heightAnchor).isActive = true
pageController.view.centerXAnchor.constraint(equalTo: self.pagingContainer.centerXAnchor).isActive = true
Here is a quick gif of what it looks like. Main view controller only has red background and a pagingContainer on the bottom half and inset of 30 on each side (to demonstrate the size of pageController being within the pagingContainer and not overflowing)

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.

How to autoresize UIView with UISegmentedControl in table view header view

When I create a UISegmentedControl and set it as a table view's header view without assigning any frame to it, the control is automatically sized to span the table view's width and keeps its intrinsic height.
let control = UISegmentedControl(items: ["one", "two"])
tableView.tableHeaderView = control
However, when I wrap the segmented control inside another UIView, the view collapses and is not resized properly.
let header = UIView()
let control = UISegmentedControl(items: ["one", "two"])
header.addSubview(control)
tableView.tableHeaderView = header
How can I make the UIView behave in the same way as the UISegmentedControl? I was not able to find any difference between the two in terms of initial configuration.
Answering myself here. I found out that initializing the view with an arbitrary-width and desired-height frame and then using autolayout to make the segmented control span the view does the trick.
let header = UIView(frame: CGRectMake(0, 0, 1, 33))
let control = UISegmentedControl(items: ["one", "two"])
control.translatesAutoresizingMaskIntoConstraints = false
header.addSubview(control)
header.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:|[control]|", options: [], metrics: nil, views: ["control": control]))
header.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:|[control]|", options: [], metrics: nil, views: ["control": control]))
tableView.tableHeaderView = header
It seems that the width of the frame is ignored and that the header view is always resized to the table view's width. Setting a dedicated height is, however, mandatory.
My guess is that
UISegmentedControl(items: ["one", "two"])
internally calls init(frame:) with a default frame which enables this mechanism. On the other hand,
UIView()
apparently calls init(frame:) with a CGRectZero which makes the header collapse.