I'm trying to create a complex sticky UICollectionView header:
it must be resizable based on specific scroll criteria, e.g. when a user scrolls past a certain y position, the header will resize.
it must automatically resize while the collection view content is scrolling
it must respond to touch events on the header itself (it can't be a background view that "appears" like a header)
Now this has proven to be quite the challenge, but I've made some progress. To simplify the problem, I'm starting by just creating a header that will resize on scroll and keep its bounds when the touch ends.
To get started, I've created a collection view and supplementary view header that looks like this in its simplest form:
private let defaultHeaderViewHeight: CGFloat = 250.0
private var beginHeaderViewHeight: CGFloat = defaultHeaderViewHeight
private var currentHeaderViewHeight: CGFloat = defaultHeaderViewHeight
private var headerView: HeaderView? {
get {
guard let headerView = collectionView?.supplementaryView(forElementKind: UICollectionElementKindSectionHeader, at: IndexPath(row: 0, section: 0)) as? HeaderView else { return nil }
return headerView
}
}
// Resizing logic
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
// Define the header size. This is called each time the layout is invalidated, and the beginHeaderViewHeight value is different each time
print("Header ref size delegate called, setting height to \(beginHeaderViewHeight)")
return CGSize(width: collectionView.frame.size.width, height: beginHeaderViewHeight)
// ^ super important!
}
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
currentHeaderViewHeight = max(0, -(collectionView?.contentOffset.y)! + beginHeaderViewHeight)
headerView?.frame.size.height = currentHeaderViewHeight
headerView?.frame.origin.y = collectionView?.contentOffset.y ?? 0
print("Did scroll\t\t\tcurrentHeaderViewHeight: \(currentHeaderViewHeight), content offset: \(scrollView.contentOffset.y)")
}
override func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
print("Will end dragging\t\theader view frame height: \(headerView?.frame.size.height), content offset: \(scrollView.contentOffset.y)")
scrollView.bounds.origin = CGPoint(x: 0, y: 0)
self.collectionView?.collectionViewLayout.invalidateLayout()
print("Will end dragging\t\theader view frame height: \(headerView?.frame.size.height), content offset: \(scrollView.contentOffset.y)")
beginHeaderViewHeight = currentHeaderViewHeight
}
// Basic UICollectionView setup
override func viewDidLoad() {
super.viewDidLoad()
collectionView?.register(UINib(nibName: "HeaderView", bundle: nil), forSupplementaryViewOfKind: UICollectionElementKindSectionHeader, withReuseIdentifier: "Header View")
}
override func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 50
}
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath)
return cell
}
This kinda works, but not entirely. When I scroll "up" (touch tracks down), it behaves as I'd expect - when the touch ends, the header resizes and sticks in place. Here's a log extract to validate:
Did scroll currentHeaderViewHeight: 447.333333333333, content offset: -70.3333333333333
Did scroll currentHeaderViewHeight: 449.0, content offset: -72.0
Did scroll currentHeaderViewHeight: 451.0, content offset: -74.0
Did scroll currentHeaderViewHeight: 451.666666666667, content offset: -74.6666666666667
Will end dragging header view frame height: Optional(451.666666666667), content offset: -74.6666666666667
Will end dragging header view frame height: Optional(451.666666666667), content offset: 0.0
Did end dragging currentHeaderViewHeight: 451.666666666667, content offset: 0.0
Header ref size delegate called, setting height to 451.666666666667
But when I scroll "down" (touch tracks up), it gets really goofed: it appears that scrollViewDidScroll is being called, even though I'm not setting the content offset anywhere:
Did scroll currentHeaderViewHeight: 330.666666666667, content offset: 121.0
Did scroll currentHeaderViewHeight: 330.333333333333, content offset: 121.333333333333
Did scroll currentHeaderViewHeight: 330.0, content offset: 121.666666666667
Did scroll currentHeaderViewHeight: 328.333333333333, content offset: 123.333333333333
Will end dragging header view frame height: Optional(328.333333333333), content offset: 123.333333333333
Will end dragging header view frame height: Optional(328.333333333333), content offset: 0.0
Did end dragging currentHeaderViewHeight: 328.333333333333, content offset: 0.0
Header ref size delegate called, setting height to 328.333333333333
// WHY ARE THESE LAST TWO LINES CALLED?
Did scroll currentHeaderViewHeight: 205.0, content offset: 123.333333333333
Did end deceler.. currentHeaderViewHeight: 205.0, content offset: 123.333333333333
So the tl;dr here: why on earth does it work when I scroll in one direction and not the other?
Bonus points: If there's a cleaner, simpler way to do all of this, feel free to share!
Related
I have a UICollectionView with horizontal scrolling and paging. The cells should be the screen size always, so when the orientation of the device changes there is the call collectionView.collectionViewLayout.invalidateLayout() and the item size and collection view offset get recalculated and it works well.
The issue occurs when going to another view controller, then rotating and going back to the view controller with the collection view. Then it looks like the collection view doesn't take into account the orientation change or something like this and there could be two cells visible, which should not happen. Also there is some weird animation when going back to the view controller with the collection view. How should these be fixed?
Here is some code:
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
collectionView.collectionViewLayout.invalidateLayout()
coordinator.animate(alongsideTransition: { (context) in
}) { (context) in
}
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return collectionView.frame.size
}
func collectionView(_ collectionView: UICollectionView, targetContentOffsetForProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
let width = collectionView.frame.size.width
let visibleCells = collectionView.visibleCells
if visibleCells.count == 0 {
return CGPoint.zero
}
let cell = visibleCells[0]
let indexPath = collectionView.indexPath(for: cell)
let index = indexPath?.item
let offsetX = CGFloat(index!) * width
let offsetY: CGFloat = 0
let offset = CGPoint(x: offsetX, y: offsetY)
return offset
}
Trying to fix the issue I make the following calls in viewDidAppear but no luck:
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
collectionView.collectionViewLayout.invalidateLayout()
collectionView.layoutSubviews()
}
I'm using a UICollectionView with a Flow Layout. I've set the header, which is a UICollectionReusableView to behave like so;
layout?.sectionHeadersPinToVisibleBounds = true
...
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
let header = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionElementKindSectionHeader, withReuseIdentifier: "header", for: indexPath as IndexPath)
header.layer.zPosition = -1
return header
}
This gives the desired effect that when scrolling the cells up, the header stays pinned but goes behind the regular cells.
However, if I try to click a UICollectionViewCell that is scrolled toward the top, i.e. so it's technically covering the UICollectionReusableView, the UICollectionViewCell's didSelectItemAt tap event no longer fires until I scroll it back down away from where the header is. In other words, the UICollectionReusableView is blocking tap gestures, even though it's zPosition is set to -1 and isn't visible.
Has anyone ever had this issue and how did you fix it?
Adding this to your Section Header view class:
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
return false
}
will pass all touches through to the next receiver below - in this case, your collection view cell. If you need an interactive element (such as a button) in the Section Header, you can do:
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let ptInSub = theButton.convert(point, from: theButton.superview)
if theButton.bounds.contains(ptInSub) {
return true
}
return false
}
This could give you what you want, although... if the Cell View is covering the Button on the Section Header, and you tap the cell where the button is, the button will take the tap. Should be able to get around that with another contains(point) or two...
I ended up adding a Parallax effect to my UICollectionReusableView header. There are a few Parallax libraries out there, but it's actually really simple - I achieved it using the following code;
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
if header != nil {
let scrollDiff = scrollView.contentOffset.y - self.previousScrollOffset
let absoluteTop: CGFloat = 0;
let absoluteBottom: CGFloat = scrollView.contentSize.height - scrollView.frame.size.height;
let isScrollingDown = scrollDiff > 0 && scrollView.contentOffset.y > absoluteTop
let isScrollingUp = scrollDiff < 0 && scrollView.contentOffset.y < absoluteBottom
var newHeight = self.headerHeight
if isScrollingDown {
newHeight = max(self.minHeaderHeight, self.headerHeight - abs(scrollDiff))
} else if isScrollingUp {
newHeight = min(self.maxHeaderHeight, self.headerHeight + abs(scrollDiff))
}
if newHeight != self.headerHeight {
self.headerHeight = newHeight
self.collectionView?.contentOffset = CGPoint(x: (self.collectionView?.contentOffset.x)!, y: self.previousScrollOffset)
self.collectionView?.collectionViewLayout.invalidateLayout()
}
self.previousScrollOffset = scrollView.contentOffset.y
}
}
And then to alter the height of the header (which is called when you invalidateLayout());
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
return CGSize(width: collectionView.bounds.width, height:self.headerHeight)
}
The result is that no overlapping ever occurs, but it still gives the desired effect that I was aiming to achieve in the first place.
I am trying to make home screen on my ios 10 app like on photo from above. Green view is actually scroll view and i set constraints for it to cover whole View. Everything on scrollView i want to make scrollable. Yellow part is collection view with prototype cell. Number of items on this view is 6. Cell consists of photo and title. Table view is list of news (photo + title). When start app in table view I load 10 last news and rest of the news I getting with "load more" mechanism. I need proper work of app even on landscape orientation. I have problem to define this layout because collection view and tableView have dynamic height and space between them must be fixed. Usually on almost all tutorials people just fixed scrollView and GridView and in that case app looks good on portrait orientation but i need a more flexibility. Is it possible to achieve this through auto layout and constraints and if yes what are correct directions
UPDATE:
Content view
Collection view
What I want to achieve is to make collection view as a two columns and 3 rows in portrait orientation and 3 columns and 2 rows on landscape. Currently I have collectionView with a scroll but I want to be expanded al the time because content of collectionView should consists of 6 highlighted news.
On viewDidLoad I tried to set table view on correct position (after collection view):
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
collection.dataSource = self
collection.delegate = self
tableView.delegate = self
tableView.dataSource = self
self.view.addConstraint(
NSLayoutConstraint(
item: tableView,
attribute: .top,
relatedBy: .equal,
toItem: collection,
attribute: .bottom,
multiplier: 1.0,
constant: 20
))
tableView.frame = CGRect(x: 0,y: collection.collectionViewLayout.collectionViewContentSize.height,width: tableView.frame.width,height: tableView.frame.width ); // set new position exactly
downloadArticles(offset: "0") {}
}
An example of what I want to achieve is:
Currently I have this:
I think I got it working like this:
import UIKit
class ViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate, UITableViewDataSource, UICollectionViewDelegateFlowLayout, UITableViewDelegate {
#IBOutlet var collectionView: UICollectionView!
#IBOutlet var tableView: UITableView!
#IBOutlet var collectionViewHeightConstraint: NSLayoutConstraint!
#IBOutlet var tableViewHeightConstraint: NSLayoutConstraint!
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
self.collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "gridCell")
self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: "listCell")
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// shrink wrap the collectionView and tableView to fit their content height snuggly
self.collectionViewHeightConstraint.constant = self.collectionView.contentSize.height
self.tableViewHeightConstraint.constant = self.tableView.contentSize.height
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}
// MARK: - CollectionView Methods -
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 6;
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "gridCell", for: indexPath)
return cell
}
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
cell.backgroundColor = UIColor.lightGray
}
func calculateGridCellSize() -> CGSize {
// -----------------------------------------------------
// Calculate the size of the grid cells
// -----------------------------------------------------
let screenWidth = self.view.frame.size.width
let screenHeight = self.view.frame.size.height
var width:CGFloat = 0
var height:CGFloat = 0
if(UIDeviceOrientationIsPortrait(UIDevice.current.orientation)) {
width = screenWidth / 2.0 - 0.5
height = width
}
else {
width = screenWidth / 3.0 - 1.0
height = screenHeight / 2.0 - 0.5
}
let size:CGSize = CGSize(width: width, height: height)
return size
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return self.calculateGridCellSize()
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
coordinator.animate(alongsideTransition: { (context) in
print("New screen size = \(size.width) x \(size.height)")
self.collectionView.collectionViewLayout.invalidateLayout()
self.collectionViewHeightConstraint.constant = self.collectionView.contentSize.height
self.tableViewHeightConstraint.constant = self.tableView.contentSize.height
self.view.layoutIfNeeded()
}) { (context) in
self.collectionViewHeightConstraint.constant = self.collectionView.contentSize.height
self.tableViewHeightConstraint.constant = self.tableView.contentSize.height
self.view.layoutIfNeeded()
}
}
// MARK: - TableView Methods -
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 10;
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "listCell", for: indexPath)
return cell
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
cell.textLabel?.text = "list cell \(indexPath.row)"
}
}
For the interface layout, I did this:
Add scrollView to main view
Pin scrollview all four sides main view
Add contentView to scrollView
Pin contentView all four sides to scrollView
Make contentView width equal to scrollView width
Add collectionView to contentView
Add tableView to contentView and vertically below collectionView
Pin left, top, right of collectionView to contentView
Pin left, bottom, right of tableView to contentView and top of tableView to bottom of collectionView
Make collectionView height 667 and create IBOutlet NSLayoutConstraint for collectionView height (so we can update it later)
Make tableView height 667 and create IBOutlet NSLayoutConstraint for tableView height (also to update later)
Make collectionView min item spacing 1 and line spacing 1
Disable scrollingEnabled for collectionView
Disable scrollingEnabled for tableView
Connect collectionView datasource and delegate to controller
Connect tableView datasource and delegate to controller
Here's a screenshot of the layout if it's any help.
Usually I build my UI using pure code and you would be able to copy and paste, hit the run button but since you're using using Storyboard, I showed it using Storyboard, hopefully you can follow my layout setup instructions.
Here's the result:
Is that what you wanted?
I set up a UICollectionView that has a following settings:
collectionView fits screen bounds
only vertical scroll is applied
most of cells fit to content's width
some of cells can change their heights on user interaction dynamically (animated)
It's pretty much like a UITableView, which works fine in most cases, except one specific situation when the animation doesn't apply.
Among stacked cells in collectionView, say one of the upper cells expands its height. Then the lower cell must be moving downwards to keep the distance. If this moving cell's target frame is out of collectionView's bounds, then no animation applies and the cell disappears.
Opposite case works the same way; if the lower cell's source frame is out of screen bounds (currently outside of the bounds) and the upper cell should shrink, no animation applies and it just appear on target frame.
This seems appropriate in memory management logic controlled by UICollectionView, but at the same time nothing natural to show users that some of contents just appear or disappear out of blue. I had tested this with UITableView and the same thing happens.
Is there a workaround for this issue?
You should add some code or at least a gif of your UI problem.
I tried to replicate your problem using a basic UICollectionViewLayout subclass :
protocol CollectionViewLayoutDelegate: AnyObject {
func heightForItem(at indexPath: IndexPath) -> CGFloat
}
class CollectionViewLayout: UICollectionViewLayout {
weak var delegate: CollectionViewLayoutDelegate?
private var itemAttributes: [UICollectionViewLayoutAttributes] = []
override func prepare() {
super.prepare()
itemAttributes = generateItemAttributes()
}
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
return collectionView?.contentOffset ?? .zero
return super.targetContentOffset(forProposedContentOffset: proposedContentOffset)
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return itemAttributes.first { $0.indexPath == indexPath }
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
return itemAttributes.filter { $0.frame.intersects(rect) }
}
private func generateItemAttributes() -> [UICollectionViewLayoutAttributes] {
var offset: CGFloat = 0
return (0..<numberOfItems()).map { index in
let indexPath = IndexPath(item: index, section: 0)
let frame = CGRect(
x: 0,
y: offset,
width: collectionView?.bounds.width ?? 0,
height: delegate?.heightForItem(at: indexPath) ?? 0
)
offset = frame.maxY
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.frame = frame
return attributes
}
}
}
In a simple UIViewController, I reloaded the first cell each time a cell is selected:
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
updatedIndexPath = IndexPath(item: 0, section: 0)
collectionView.reloadItems(at: [updatedIndexPath])
}
In that case, I faced an animation like this:
How to fix it ?
I think you could try to tweak the attributes returned by super.finalLayoutAttributesForDisappearingItem(at: itemIndexPath) computing its correct frame and play with the z-index.
But you could also simply try to invalidate all the layout like so:
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
layout = CollectionViewLayout()
layout.delegate = self
collectionView.setCollectionViewLayout(layout, animated: true)
}
and override:
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
return collectionView?.contentOffset ?? .zero
}
to avoid a wrong target content offset computation when the layout is invalidated.
Here's a UICollectionView, and the cell in purple:
Quite simply, I want the cells to be 1/2 of the collection view width. (So TBC, it will be a two rows arrangement of cells in the collection view.)
(The collection view is simply fullscreen, so each cell is half the screen width.)
How do you do this in storyboard?
If I try to control-drag in the normal way, it basically doesn't work.
These are simple totally static cells (not dynamic).
For anyone googling here, to save your time: Here's exactly (2016) the simplest way to make a two-across UICollectionView layout; no gaps between the cells.
// Two - two-across UICollectionView
// use a completely standard UIViewController on the storyboard,
// likely change scroll direction to vertical.
// name the cell identifier "cellTwo" on the storyboard
import UIKit
class Two:UICollectionViewController
{
override func viewDidLoad()
{
super.viewDidLoad()
let w = collectionView!.bounds.width / 2.0
let layout: UICollectionViewFlowLayout = UICollectionViewFlowLayout()
layout.sectionInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
layout.itemSize = CGSize(width:w,height:w)
layout.minimumInteritemSpacing = 0
layout.minimumLineSpacing = 0
collectionView!.collectionViewLayout = layout
// Note!! DO NOT!!! register if using a storyboard cell!!
// do NOT do this:
// self.collectionView!.registerClass(UICollectionViewCell.self, forCellWithReuseIdentifier: reuseIdentifier)
}
override func numberOfSectionsInCollectionView(collectionView: UICollectionView) -> Int
{ return 1 }
override func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int
{ return 5 }
override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell
{
return collectionView.dequeueReusableCellWithReuseIdentifier("cellTwo", forIndexPath: indexPath)
}
}
You can't do it in the storyboard. The collection view width is not known until runtime, and collection view cells are not under autolayout, so you cannot express the notion "1/2 the width" of anything else. (If you did know the collection view width in advance, you could use the flow layout in the storyboard to set the cell size absolutely, by dividing in your head; but you don't know it, because the width differs depending on the device.)