UIScrollView excessive content height when setting contentInset - ios

I use following code (AutoLayout, SnapKit) to set up scrollView:
public lazy var contentView = UIView()
public lazy var scrollView = UIScrollView()
func addScrollView(insets: UIEdgeInsets, safeAreaRelatedSides: [Side]) {
view.addSubview(scrollView)
scrollView.addSubview(contentView)
scrollView.snp.makeConstraints { make in
make.top.equalTo(safeAreaRelatedSides.contains(where: { $0 == .top }) ? view.safeAreaLayoutGuide.snp.top : view.snp.top)
make.left.equalTo(safeAreaRelatedSides.contains(where: { $0 == .left }) ? view.safeAreaLayoutGuide.snp.left : view.snp.left)
make.right.equalTo(safeAreaRelatedSides.contains(where: { $0 == .right }) ? view.safeAreaLayoutGuide.snp.right : view.snp.right)
make.bottom.equalTo(safeAreaRelatedSides.contains(where: { $0 == .bottom }) ? view.safeAreaLayoutGuide.snp.bottom : view.snp.bottom)
}
contentView.snp.makeConstraints { make in
make.top.equalToSuperview().inset(insets.top)
make.left.equalToSuperview().inset(insets.left)
make.right.equalToSuperview().inset(insets.right)
make.bottom.equalToSuperview().inset(insets.bottom).priority(250)
make.centerX.equalToSuperview()
make.centerY.equalToSuperview().priority(250)
}
scrollView.contentInsetAdjustmentBehavior = .never
}
It works great. But When I want scroll view's bottom constraint to depend on superview and not its safe area (to let excessive content height be visible below safe area), I call:
addScrollView(insets: someValue, safeAreaRelatedSides: [.top, .left, .right])
scrollView.contentInset.bottom = UIApplication.shared.safeAreaInsets.bottom
In result, contentView layouts its height as:
expectedContentViewHeight + scrollView.contentInset.bottom
A view with lowest contenHuggingPriority gets stretched by scrollView.contentInset.bottom more than it should. And now scrollView.contentSize.height exceeds scrollView.bounds.height with nothing forcing that.
This only happens when contentView height is less than screen height.
First screenshot represents initial UI. Second one - after scrolling to the very bottom
What can I do to prevent contentView height from this excessive growing?

Related

How do you remove constraint centerXAnchor?

I have a button placed in the center using centerXAnchor of superview, but now I have to change the position of the button from centerX to align leading from code. However, it's not moving to the left. Instead, it gets full width button.
buttonView!.translatesAutoresizingMaskIntoConstraints = false
buttonView!.removeConstraints(buttonView!.constraints)
NSLayoutConstraint.activate([
buttonView!.leadingAnchor.constraint(equalTo: mainView.leadingAnchor, constant: 12),
buttonView!.bottomAnchor.constraint(equalTo: mainView.bottomAnchor, constant: 20),
])
Removing/add constraints doesn't cause them to be (re)applied.
Call .setNeedsUpdateConstraints() on your view. The system will then call updateConstraints as part of its next layout pass. For complex constraint scenarios you may need your own implementation of updateConstraints, but for most cases, and definitely yours, this won't be needed (and should generally be avoided unless there is a specific reason to use it - see the docs)
first remove all Constraint
extension UIView {
public func removeAllConstraints() {
var _superview = self.superview
while let superview = _superview {
for constraint in superview.constraints {
if let first = constraint.firstItem as? UIView, first == self {
superview.removeConstraint(constraint)
}
if let second = constraint.secondItem as? UIView, second == self {
superview.removeConstraint(constraint)
}
}
_superview = superview.superview
}
self.removeConstraints(self.constraints)
self.translatesAutoresizingMaskIntoConstraints = true
self.setNeedsUpdateConstraints()
}
}
to use it
DispatchQueue.main.async { [weak self] in
self?.buttonView.removeAllConstraints()
// then you can add your constraint as you like
}
ref
There are various ways to do this, depending on exactly what you need to accomplish.
First, you said you're laying out your views in Storyboard, so...
If we're talking about one (or a few) specific views, we can create #IBOutlet vars for the constraints you want to change.
In Storyboard:
give your buttonView a centerX constraint, with Priority: Required (1000)
give your buttonView your desired leading constraint, with Priority: Low (250)
Connect them to outlets:
#IBOutlet var buttonCenterConstraint: NSLayoutConstraint!
#IBOutlet var buttonLeadingConstraint: NSLayoutConstraint!
Then, to switch from centered to leading:
buttonCenterConstraint.priority = .defaultLow
buttonLeadingConstraint.priority = .required
and you can "toggle" it back to centered with:
buttonLeadingConstraint.priority = .defaultLow
buttonCenterConstraint.priority = .required
Perhaps do the same thing with centerY and bottom constraints.
If you want a little more "flexibility," you could do something like this:
in Storyboard, set only the centerX constraint
Then, to change that to a leading constraint:
// find the centerX constraint and de-activate it
if let cxConstraint = mainView.constraints.first(where: { ($0.firstAttribute == .centerX && $0.firstItem === buttonView) }) {
cxConstraint.isActive = false
} else if let cxConstraint = mainView.constraints.first(where: { ($0.firstAttribute == .centerX && $0.secondItem === buttonView) }) {
cxConstraint.isActive = false
}
// add a new leading constraint
buttonView.leadingAnchor.constraint(equalTo: mainView.leadingAnchor, constant: 12.0).isActive = true
You could also use an extension as suggested by someone else to "remove all constraints" ... but you risk removing constraints that you do not want changed.

Animate the height of a UIScrollView based on it's content

My situation:
I have a horizontal ScrollView containing a StackView.
Inside this StackView there are some Views, that can be expanded/collapsed.
When I want to expand one of these Views, I first unhide some subViews in the View. After that I need to change the height of the ScrollView based on the new height of this View.
But this is not working...
I try this code:
UIView.animate(withDuration: 0.3) { [self] in
// Toggle hight of all subViews
stackView.arrangedSubviews.forEach { itemView in
guard let itemView = itemView as? MyView else { return }
itemView.toggleView()
}
// Now update the hight of the StackView
// But here the hight is always from the previous toggle
let height = self.stackView.arrangedSubviews.map {$0.frame.size.height}.max() ?? 0.0
print(height)
heightConstraint.constant = height
}
This code nicely animates, but always to the wrong height.
So the ScrollView animates to collapsed when it should be expanded and expanded when it should be collapsed.
Anyone with on idea how to solve this?
The problem is that, whatever you are doing here:
itemView.toggleView()
may have done something to change the height a view, but then you immediately call:
let height = self.stackView.arrangedSubviews.map {$0.frame.size.height}.max() ?? 0.0
before UIKit has updated the frames.
So, you can either track your own height property, or...
get the frame heights after the update - such as with:
DispatchQueue.main.async {
let height = self.stackView.arrangedSubviews.map {$0.frame.size.height}.max() ?? 0.0
print("h", height)
self.scrollHeightConstraint.constant = height
UIView.animate(withDuration: 0.3) {
self.view.layoutIfNeeded()
}
}

Stretchy Layout not working with child view controller

I'm trying to follow the example described here for making a stretchy layout which includes a UIImageView and UIScrollView. https://github.com/TwoLivesLeft/StretchyLayout/tree/Step-6
The only difference is that I replace the UILabel used in the example with the view of a child UIViewController which itself contains a UICollectionView. This is how my layout looks - the blue items are the UICollectionViewCell.
This is my code:
import UIKit
import SnapKit
class HomeController: UIViewController, UIScrollViewDelegate {
private let scrollView = UIScrollView()
private let imageView = UIImageView()
private let contentContainer = UIView()
private let collectionViewController = CollectionViewController()
override var preferredStatusBarStyle: UIStatusBarStyle {
return .lightContent
}
override func viewDidLoad() {
super.viewDidLoad()
scrollView.contentInsetAdjustmentBehavior = .never
scrollView.delegate = self
imageView.image = UIImage(named: "burger")
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
let imageContainer = UIView()
imageContainer.backgroundColor = .darkGray
contentContainer.backgroundColor = .clear
let textBacking = UIView()
textBacking.backgroundColor = #colorLiteral(red: 0.7450980544, green: 0.1235740449, blue: 0.2699040081, alpha: 1)
view.addSubview(scrollView)
scrollView.addSubview(imageContainer)
scrollView.addSubview(textBacking)
scrollView.addSubview(contentContainer)
scrollView.addSubview(imageView)
self.addChild(collectionViewController)
contentContainer.addSubview(collectionViewController.view)
collectionViewController.didMove(toParent: self)
scrollView.snp.makeConstraints {
make in
make.edges.equalTo(view)
}
imageContainer.snp.makeConstraints {
make in
make.top.equalTo(scrollView)
make.left.right.equalTo(view)
make.height.equalTo(imageContainer.snp.width).multipliedBy(0.7)
}
imageView.snp.makeConstraints {
make in
make.left.right.equalTo(imageContainer)
//** Note the priorities
make.top.equalTo(view).priority(.high)
//** We add a height constraint too
make.height.greaterThanOrEqualTo(imageContainer.snp.height).priority(.required)
//** And keep the bottom constraint
make.bottom.equalTo(imageContainer.snp.bottom)
}
contentContainer.snp.makeConstraints {
make in
make.top.equalTo(imageContainer.snp.bottom)
make.left.right.equalTo(view)
make.bottom.equalTo(scrollView)
}
textBacking.snp.makeConstraints {
make in
make.left.right.equalTo(view)
make.top.equalTo(contentContainer)
make.bottom.equalTo(view)
}
collectionViewController.view.snp.makeConstraints {
make in
make.left.right.equalTo(view)
make.top.equalTo(contentContainer)
make.bottom.equalTo(view)
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
scrollView.scrollIndicatorInsets = view.safeAreaInsets
scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: view.safeAreaInsets.bottom, right: 0)
}
//MARK: - Scroll View Delegate
private var previousStatusBarHidden = false
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if previousStatusBarHidden != shouldHideStatusBar {
UIView.animate(withDuration: 0.2, animations: {
self.setNeedsStatusBarAppearanceUpdate()
})
previousStatusBarHidden = shouldHideStatusBar
}
}
//MARK: - Status Bar Appearance
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
return .slide
}
override var prefersStatusBarHidden: Bool {
return shouldHideStatusBar
}
private var shouldHideStatusBar: Bool {
let frame = contentContainer.convert(contentContainer.bounds, to: nil)
return frame.minY < view.safeAreaInsets.top
}
}
Everything is the same as in this file: https://github.com/TwoLivesLeft/StretchyLayout/blob/Step-6/StretchyLayouts/StretchyViewController.swift with the exception of the innerText being replaced by my CollectionViewController.
As you can see, the UICollectionView is displayed properly - however I am unable to scroll up or down anymore. I'm not sure where my mistake is.
It looks like you are constraining the size of your collection view to fit within the bounds of the parent view containing the collection view's container view and the image view. As a result, the container scrollView has no contentSize to scroll over, and that's why you can't scroll. You need to ensure your collection view's content size is reflected in the parent scroll view's content size.
In the example you gave, this behavior was achieved by the length of the label requiring a height greater than the height between the image view and the rest of the view. In your case, the collection view container needs to behave as if it's larger than that area.
Edit: More precisely you need to pass the collectionView.contentSize up to your scrollView.contentSize. A scrollview's contentSize is settable, so you just need to increase the scrollView.contentSize by the collectionView.contentSize - collectionView.height (since your scrollView's current contentSize currently includes the collectionView's height). I'm not sure how you are adding your child view controller, but at the point you do that, I would increment your scrollView's contentSize accordingly. If your collectionView's size changes after that, though, you'll also need to ensure you delegate that change up to your scrollView. This could be accomplished by having a protocol such as:
protocol InnerCollectionViewHeightUpdated {
func collectionViewContentHeightChanged(newSize: CGSize)
}
and then making the controller containing the scrollView implement this protocol and update the scrollView contentSize accordingly. From your collectionView child controller, you would have a delegate property for this protocol (set this when creating the child view controller, setting the delegate as self, the controller containing the child VC and also the scrollView). Then whenever the collectionView height changes (if you add cells, for example) you can do delegate.collectionViewContentHeightChanged(... to ensure your scroll behavior will continue to function.

iOS tableview inside pageviewcontroller inside scrollview

I want to put a tableView inside a pageviewcontroller which is inside a scrollview.
So I have in my storyboard :
All constraints are respected like this
I disabled the tableview scroll but my scroll doesn't scroll, my tableView in TestVC1 not expand the PagerPlace in TestVC
How can I make my scrollview scroll and its content size depends on the tableview height + my red view?
EDIT
I tried your solution, then I got a storyboard like this :
storyboard
Then my scrollview doesn't scroll, I don't know why,
In order to make your effect perfect.
Scroll View -> UIPageViewController's view -> UITableView
Scroll View has a subview of UIPageViewController's view,
UIPageViewController has many page, one page ( a controller's view ) has a subview of UITableView
Yeah. You can change the solution.
mainScrollView ( vertical slide ) -> contentScrollView ( horizontal slide ) -> contentStackView ( has many pages) -> UITableView ( one page )
mainScrollView is UIScrollView, slides in vertical,
contentScrollView is UIScrollView, slides in horizontal
contentScrollView.isPagingEnabled = true
that simulates UIPageViewController
contentStackView has many pages, one page is your UITableView
To make it work like this:
How can I make my scrollview scroll and its content size depends on the tableview height + my red view?
for the part above the UITableView
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
if scrollView == mainScrollView {
// vertical
let offsetY = scrollView.contentOffset.y
if offsetY >= sillValue {
scrollView.contentOffset = CGPoint(x: 0, y: sillValue)
currentChildScrollView?.am_isCanScroll = true
scrollView.am_isCanScroll = false
} else {
let negScroll = (scrollView.am_isCanScroll == false)
if negScroll{
scrollView.contentOffset = CGPoint(x: 0, y: sillValue)
}
}
}
}
for the UITableView part
use KVO to controller the base scroll view's offset Y ,
and UITableView's offset Y is by default.
let keyValueObservation = currentChildScrollView?.observe(\.contentOffset, options: [.new, .old], changeHandler: { [weak self] (scrollView, change) in
guard let self = self, change.newValue != change.oldValue else {
return
}
self.childScrollView(didScroll: scrollView)
})
internal func childScrollView(didScroll scrollView: UIScrollView){
let scrollOffset = scrollView.am_originOffset.val
let offsetY = scrollView.contentOffset.y
if scrollView.am_isCanScroll == false {
scrollView.contentOffset = scrollOffset
}
else if offsetY <= scrollOffset.y {
scrollView.contentOffset = scrollOffset
scrollView.am_isCanScroll = false
mainScrollView.am_isCanScroll = true
}
}
the full code in github

Autolayout: How to compress a complex popover

If you like to try the source code (which you are very welcome to do), have a look at my Bitbucket repository.
I have a popover dialogue that shows a list of settings. These settings a listed inside multiple UITableViews. The UITableViews shall not be scrollable, for the overall settings view already is. Furthermore, the popover dialogue shall take as much screen vertically as it needs but shall be horizontally compressed.
Thus, I conceived the following structure:
UIView => MySettingsViewController
- UIScrollView
- UIView (Content View)
- Container View1
- UITableView (embedded) => MyTableViewController
- Container View2
- UITableView (embedded)
The structure is assembled via Interface Builder and Autolayout is used for the sizing.
I have both the Scroll View, the Content View (I started with just one) and the Container View to their respective superviews (or layout guides). I constrained the size of the content view in the following manner:
contentView.width == (topmost) UIView.width
contentView.height == 200 // removed at build time
Additionally, I set the size of the table view to its content size, because otherwise the popover appears to be empty:
class MyTableViewController: UITableViewController {
override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)
// this is Cartography syntax - the intention should be clear
layout(view, replace: ConstraintGroup()) { [unowned self] view in
view.width == self.tableView.contentSize.width
view.height == self.tableView.contentSize.height
}
view.setNeedsLayout()
}
}
The settings popover is filled with content, but its size is not quite right:
To fix this, I tried the following approach which does not work:
class MySettingsViewController: UIViewController {
override var preferredContentSize: CGSize {
get {
let compressedSize = view.systemLayoutSizeFittingSize(UILayoutFittingCompressedSize)
// this is always (0, 0) because the subviews are not resized, yet
return compressedSize
}
set {
super.preferredContentSize = newValue
}
}
}
To conclude: The compression does not work.
So I just fixed the problem myself as you can see when looking at the Bitbucket repository.
The layout is now fixed both in MyTableViewController and MySettingsViewController. The former one now looks like this:
class MyTableViewController: UITableViewController {
var heightConstraint: NSLayoutConstraint?
var tableViewEdgesConstraints: [NSLayoutConstraint]?
override func viewWillAppear(animated: Bool) {
super.viewWillAppear(animated)
if let container = tableView.superview where tableViewEdgesConstraints == nil {
layout(tableView, container, replace: ConstraintGroup()) { [unowned self] tableView, container in
self.tableViewEdgesConstraints = tableView.edges == inset(container.edges, 0)
}
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if let heightConstraint = heightConstraint {
if Int(heightConstraint.constant) != Int(tableView.contentSize.height) {
heightConstraint.constant = self.tableView.contentSize.height
}
} else {
layout(view, replace: ConstraintGroup()) { [unowned self] view in
if (self.tableView.contentSize.height > 0) {
self.heightConstraint = view.height == self.tableView.contentSize.height
}
}
}
}
}
So basically, I constraint the height of the table to its content's height and change the constraint if the content's height changes. This is done as soon as the table is laid out. Furthermore, the nested table view is pinned by its edges to the edges of the container view. I think that this is mandatory because I could not find out how to constrain two views of different scenes right in Interface Builder.
In MySettingsViewController the scrollview's size is set to the size of the content view's frame (which is accessible via an outlet) as soon as this size is known. Furthermore, to make the popover compress, the preferredContentSize of the settings controller is adapted accordingly, when the height changes (if you omit the condition you might get yourself in a layout endless loop. Furthermore I did 3 things to make it possible to have a navigation controller wrapped around MySettingsViewController:
The width of the popover is set to a fixed value (otherwise it would sometimes expand to the full width).
The presentedViewController's preferredContentSize needs to be set equally.
I had to set the insets of the scrollView to 0 to avoid an ugly vertical offset - this solution is sub-optimal because it breaks the scroll view experience a bit. But it works.
Here is the code:
class MySettingsViewController: UIViewController {
#IBOutlet weak var contentView: UIView!
#IBOutlet weak var scrollView: UIScrollView!
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
scrollView.contentSize = contentView.frame.size
if (preferredContentSize.height != scrollView.contentSize.height) {
let newSize = CGSize(width: 400, height: scrollView.contentSize.height)
preferredContentSize = newSize
presentingViewController?.presentedViewController?.preferredContentSize = newSize
scrollView.contentInset = UIEdgeInsetsMake(0, 0, 0, 0)
}
}
}
And this is the result:

Resources